adapt windows
This commit is contained in:
11
README.md
11
README.md
@@ -1,9 +1,8 @@
|
|||||||
# LoveLiveMusicPlayer · 桌面端(Apple 风格重构版)
|
# LoveLiveMusicPlayer · 桌面端
|
||||||
|
|
||||||
基于最新 **electron-vite** 脚手架重构的 LoveLive! 音乐播放器 PC 端,复刻原 Electron 项目全部功能,
|
基于 **electron-vite** 的 LoveLive! 音乐播放器 PC 端,复刻原 Electron 项目功能。
|
||||||
UI 采用 **Apple / macOS 风格**(毛玻璃、圆角、SF 字体、系统色板、深浅色)。
|
|
||||||
|
|
||||||
> 设计目标之一:**为后续移动端(Flutter)重构预留最大兼容性与可扩展性**。
|
> 设计目标之一:**为后续移动端(Flutter)预留最大兼容性与可扩展性**。
|
||||||
> 双端联动协议被抽取为独立、框架无关的共享模块 `src/shared/protocol`,移动端可直接对照复用。
|
> 双端联动协议被抽取为独立、框架无关的共享模块 `src/shared/protocol`,移动端可直接对照复用。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
@@ -13,7 +12,7 @@ UI 采用 **Apple / macOS 风格**(毛玻璃、圆角、SF 字体、系统色
|
|||||||
| 框架 | Electron 43 + Vite 7 + React 19 + TypeScript |
|
| 框架 | Electron 43 + Vite 7 + React 19 + TypeScript |
|
||||||
| 脚手架 | electron-vite |
|
| 脚手架 | electron-vite |
|
||||||
| 状态管理 | Zustand |
|
| 状态管理 | Zustand |
|
||||||
| 样式 | Tailwind CSS 3 + 自研 Apple 设计系统(`src/renderer/src/styles/index.css`) |
|
| 样式 | Tailwind CSS 3(`src/renderer/src/styles/index.css`) |
|
||||||
| 本地数据库 | better-sqlite3(表结构与移动端 SQLite 对齐) |
|
| 本地数据库 | better-sqlite3(表结构与移动端 SQLite 对齐) |
|
||||||
| 配置存储 | electron-store |
|
| 配置存储 | electron-store |
|
||||||
| 局域网通信 | ws(WebSocket 服务端)+ express(HTTP 文件服务),**运行在主进程** |
|
| 局域网通信 | ws(WebSocket 服务端)+ express(HTTP 文件服务),**运行在主进程** |
|
||||||
@@ -56,7 +55,7 @@ src/
|
|||||||
- 本地 HTTP 文件服务(端口探测、可配置)
|
- 本地 HTTP 文件服务(端口探测、可配置)
|
||||||
- iOS flac→wav 转码
|
- iOS flac→wav 转码
|
||||||
- 设置:曲库目录、HTTP 端口、主题(深/浅/跟随系统)、强调色
|
- 设置:曲库目录、HTTP 端口、主题(深/浅/跟随系统)、强调色
|
||||||
- 无边框窗口 + Apple 质感(Windows 自定义控制按钮 / macOS 红绿灯位)
|
- 无边框窗口(Windows 自定义控制按钮 / macOS 红绿灯位)
|
||||||
|
|
||||||
## 与移动端的兼容性设计
|
## 与移动端的兼容性设计
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { resolve } from 'path'
|
|||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
/** Windows 上原生 fs.watch 对部分盘符/杀软常漏事件,用轮询保证 HMR */
|
||||||
|
const watchOptions =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? {
|
||||||
|
usePolling: true,
|
||||||
|
interval: 150,
|
||||||
|
awaitWriteFinish: { stabilityThreshold: 80, pollInterval: 40 }
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
plugins: [externalizeDepsPlugin()],
|
plugins: [externalizeDepsPlugin()],
|
||||||
@@ -22,9 +32,16 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
// 绑定 IPv4,避免仅监听 ::1 导致 Electron 用 localhost(127.0.0.1) 连接被拒
|
// 绑定 IPv4,避免仅监听 ::1 导致 Electron 用 localhost(127.0.0.1) 连接被拒
|
||||||
|
// 注意:不要再写 hmr.port=5173,会另起 WS 占口导致热更新假连上、不推送
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 5173
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
...(watchOptions ? { watch: watchOptions } : {}),
|
||||||
|
hmr: {
|
||||||
|
protocol: 'ws',
|
||||||
|
host: '127.0.0.1'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "lovelive-music-player-next",
|
"name": "lovelive-music-player-next",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"description": "LoveLiveMusicPlayer 桌面端(Apple 风格重构版)",
|
"description": "LoveLiveMusicPlayer 桌面端",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "zhushenwudi",
|
"author": "zhushenwudi",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
"start": "env -u ELECTRON_RUN_AS_NODE -u ATOM_SHELL_INTERNAL_RUN_AS_NODE electron-vite preview",
|
"start": "node scripts/run-electron-vite.mjs preview",
|
||||||
"dev": "node scripts/patch-electron-dev-name.mjs && env -u ELECTRON_RUN_AS_NODE -u ATOM_SHELL_INTERNAL_RUN_AS_NODE electron-vite dev",
|
"dev": "node scripts/patch-electron-dev-name.mjs && node scripts/run-electron-vite.mjs dev",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "npm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps && node scripts/patch-electron-dev-name.mjs",
|
"postinstall": "electron-builder install-app-deps && node scripts/patch-electron-dev-name.mjs",
|
||||||
"build:win": "npm run build && node scripts/run-electron-builder.mjs --win",
|
"build:win": "npm run build && node scripts/run-electron-builder.mjs --win",
|
||||||
|
|||||||
63
scripts/run-electron-vite.mjs
Normal file
63
scripts/run-electron-vite.mjs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 跨平台启动 electron-vite,并清除 Cursor/VS Code 注入的
|
||||||
|
* ELECTRON_RUN_AS_NODE / ATOM_SHELL_INTERNAL_RUN_AS_NODE(否则 Electron 会当 Node 跑)。
|
||||||
|
*
|
||||||
|
* Windows 上用 spawn + taskkill /T,避免终端关掉后留下无 Vite 的孤儿 Electron(热更新假死)。
|
||||||
|
*/
|
||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const cli = join(root, 'node_modules/electron-vite/bin/electron-vite.js')
|
||||||
|
|
||||||
|
const env = { ...process.env }
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE
|
||||||
|
delete env.ATOM_SHELL_INTERNAL_RUN_AS_NODE
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
env.CHOKIDAR_USEPOLLING = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(process.execPath, [cli, ...process.argv.slice(2)], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env,
|
||||||
|
cwd: root,
|
||||||
|
shell: false,
|
||||||
|
windowsHide: false
|
||||||
|
})
|
||||||
|
|
||||||
|
let shuttingDown = false
|
||||||
|
function killTree() {
|
||||||
|
if (shuttingDown || !child.pid) return
|
||||||
|
shuttingDown = true
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
child.kill('SIGTERM')
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
||||||
|
process.on(sig, () => {
|
||||||
|
killTree()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
process.on('exit', killTree)
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
console.error('[run-electron-vite]', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
shuttingDown = true
|
||||||
|
if (signal) process.exit(1)
|
||||||
|
process.exit(code ?? 1)
|
||||||
|
})
|
||||||
@@ -10,11 +10,7 @@ import { initDatabase } from './services/database'
|
|||||||
import { stopFileServer } from './services/fileServer'
|
import { stopFileServer } from './services/fileServer'
|
||||||
import { stopMusicServer } from './services/musicServer'
|
import { stopMusicServer } from './services/musicServer'
|
||||||
import { stopDataServer } from './services/dataServer'
|
import { stopDataServer } from './services/dataServer'
|
||||||
import {
|
import { registerMediaShortcuts, unregisterMediaShortcuts } from './services/mediaShortcuts'
|
||||||
bindSeekShortcuts,
|
|
||||||
registerMediaShortcuts,
|
|
||||||
unregisterMediaShortcuts
|
|
||||||
} from './services/mediaShortcuts'
|
|
||||||
|
|
||||||
/** 开发态仍运行 Electron.app,需手动改 Dock 图标;菜单栏名称仍会显示 Electron(Info.plist 限制) */
|
/** 开发态仍运行 Electron.app,需手动改 Dock 图标;菜单栏名称仍会显示 Electron(Info.plist 限制) */
|
||||||
function applyDevBranding(): void {
|
function applyDevBranding(): void {
|
||||||
@@ -74,15 +70,13 @@ if (!gotLock) {
|
|||||||
input.code === 'Numpad0'
|
input.code === 'Numpad0'
|
||||||
if (zoomKey) event.preventDefault()
|
if (zoomKey) event.preventDefault()
|
||||||
})
|
})
|
||||||
// ⌘/Win + ←/→ 快退/快进 5s(仅聚焦时)
|
|
||||||
bindSeekShortcuts(window)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
initDatabase()
|
initDatabase()
|
||||||
registerIpc()
|
registerIpc()
|
||||||
createMainWindow()
|
createMainWindow()
|
||||||
createTray()
|
createTray()
|
||||||
// ⌘/Win + P 播放/暂停(全局,托盘后台也可用)
|
// Ctrl/⌘ + P 播放/暂停(全局,托盘后台也可用;与原项目一致)
|
||||||
registerMediaShortcuts()
|
registerMediaShortcuts()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { ipcMain, dialog, BrowserWindow, shell } from 'electron'
|
import { ipcMain, dialog, BrowserWindow, shell } from 'electron'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { writeFile } from 'fs/promises'
|
||||||
|
import { normalize, resolve, sep } from 'path'
|
||||||
import { IPC } from '@shared/ipc/channels'
|
import { IPC } from '@shared/ipc/channels'
|
||||||
import { getStore } from '../services/store'
|
import { getStore } from '../services/store'
|
||||||
import { dbQuery, dbExec } from '../services/database'
|
import { dbQuery, dbExec } from '../services/database'
|
||||||
@@ -133,6 +136,54 @@ export function registerIpc(): void {
|
|||||||
const r = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] })
|
const r = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] })
|
||||||
return r.canceled ? null : r.filePaths
|
return r.canceled ? null : r.filePaths
|
||||||
})
|
})
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.DIALOG_SAVE_TEXT,
|
||||||
|
async (
|
||||||
|
e,
|
||||||
|
opts: { defaultPath?: string; content: string; filters?: { name: string; extensions: string[] }[] }
|
||||||
|
): Promise<{ ok: boolean; canceled?: boolean; path?: string; error?: string }> => {
|
||||||
|
const win = BrowserWindow.fromWebContents(e.sender)
|
||||||
|
const saveOpts = {
|
||||||
|
defaultPath: opts.defaultPath,
|
||||||
|
filters: opts.filters ?? [{ name: 'LRC', extensions: ['lrc'] }]
|
||||||
|
}
|
||||||
|
const r = win
|
||||||
|
? await dialog.showSaveDialog(win, saveOpts)
|
||||||
|
: await dialog.showSaveDialog(saveOpts)
|
||||||
|
if (r.canceled || !r.filePath) return { ok: false, canceled: true }
|
||||||
|
try {
|
||||||
|
await writeFile(r.filePath, opts.content ?? '', 'utf8')
|
||||||
|
return { ok: true, path: r.filePath }
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 打开本地曲库内的目录/文件(相对文件服务根;防目录穿越) */
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.SHELL_OPEN_LOCAL,
|
||||||
|
async (
|
||||||
|
_e,
|
||||||
|
relPath: string
|
||||||
|
): Promise<{ ok: boolean; error?: string }> => {
|
||||||
|
const status = getFileServerStatus()
|
||||||
|
if (!status.running || !status.root) {
|
||||||
|
return { ok: false, error: '本地文件服务未运行' }
|
||||||
|
}
|
||||||
|
const root = resolve(status.root)
|
||||||
|
const target = resolve(root, normalize(String(relPath || '').replace(/^[/\\]+/, '')))
|
||||||
|
const rootPrefix = root.endsWith(sep) ? root : root + sep
|
||||||
|
if (target !== root && !target.startsWith(rootPrefix)) {
|
||||||
|
return { ok: false, error: '路径不在曲库目录内' }
|
||||||
|
}
|
||||||
|
if (!existsSync(target)) {
|
||||||
|
return { ok: false, error: '目录不存在,请确认本地曲库是否完整' }
|
||||||
|
}
|
||||||
|
const err = await shell.openPath(target)
|
||||||
|
return err ? { ok: false, error: err } : { ok: true }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/* ---------- 桌面歌词 ---------- */
|
/* ---------- 桌面歌词 ---------- */
|
||||||
ipcMain.handle(IPC.LYRIC_TOGGLE, (_e, show: boolean) => {
|
ipcMain.handle(IPC.LYRIC_TOGGLE, (_e, show: boolean) => {
|
||||||
|
|||||||
@@ -44,23 +44,32 @@ function enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 探测可用端口(占用则 +1,直到上限) */
|
/** 尝试在指定 host 上绑定端口;成功则立刻释放 */
|
||||||
function findAvailablePort(start: number): Promise<number> {
|
function canBind(port: number, host: string): Promise<boolean> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve) => {
|
||||||
const tryPort = (port: number): void => {
|
|
||||||
if (port > HTTP_PORT_RANGE.MAX) {
|
|
||||||
reject(new Error('没有可用端口'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const tester = net.createServer()
|
const tester = net.createServer()
|
||||||
tester.once('error', () => tryPort(port + 1))
|
tester.once('error', () => resolve(false))
|
||||||
tester.once('listening', () => {
|
tester.once('listening', () => {
|
||||||
tester.close(() => resolve(port))
|
tester.close(() => resolve(true))
|
||||||
|
})
|
||||||
|
tester.listen(port, host)
|
||||||
})
|
})
|
||||||
tester.listen(port, '0.0.0.0')
|
|
||||||
}
|
}
|
||||||
tryPort(start)
|
|
||||||
})
|
/**
|
||||||
|
* 探测可用端口(占用则 +1,直到上限)。
|
||||||
|
* 同时探测 0.0.0.0(LAN)与 127.0.0.1(播放器 URL):
|
||||||
|
* 仅一侧可绑时仍算占用——例如百度网盘只占 127.0.0.1:10000,
|
||||||
|
* 若只测 0.0.0.0 会误判可用,浏览器访问 127.0.0.1:10000 却打到对方进程。
|
||||||
|
*/
|
||||||
|
async function findAvailablePort(start: number): Promise<number> {
|
||||||
|
for (let port = start; port <= HTTP_PORT_RANGE.MAX; port++) {
|
||||||
|
const lanOk = await canBind(port, '0.0.0.0')
|
||||||
|
if (!lanOk) continue
|
||||||
|
const loopbackOk = await canBind(port, '127.0.0.1')
|
||||||
|
if (loopbackOk) return port
|
||||||
|
}
|
||||||
|
throw new Error('没有可用端口')
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileServerStatus {
|
export interface FileServerStatus {
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { globalShortcut, type BrowserWindow } from 'electron'
|
import { globalShortcut } from 'electron'
|
||||||
import { sendPlayerControl } from './playerControl'
|
import { sendPlayerControl } from './playerControl'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 媒体快捷键:
|
* 媒体快捷键:
|
||||||
* - 播放/暂停:全局 Super+P(macOS ⌘P / Windows Win+P),托盘后台也可用
|
* - 播放/暂停:全局 CommandOrControl+P(macOS ⌘P / Windows Ctrl+P),与原项目一致
|
||||||
* - 快退/快进:仅窗口聚焦时 Super+← / Super+→,避免全局抢占方向键
|
* - 快退/快进:由渲染进程监听 ← / →(与原项目一致,见 playerStore)
|
||||||
*
|
|
||||||
* Electron 的 Super = Windows 徽标键 / macOS Command。
|
|
||||||
* 注意:Windows 系统常占用 Win+P(投影),注册失败时会打日志。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const GLOBAL_PLAY_PAUSE = 'Super+P'
|
const GLOBAL_PLAY_PAUSE = 'CommandOrControl+P'
|
||||||
|
|
||||||
export function registerMediaShortcuts(): void {
|
export function registerMediaShortcuts(): void {
|
||||||
try {
|
try {
|
||||||
@@ -32,22 +29,3 @@ export function unregisterMediaShortcuts(): void {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 窗口聚焦时的快退 / 快进(不注册全局,避免影响其它应用) */
|
|
||||||
export function bindSeekShortcuts(window: BrowserWindow): void {
|
|
||||||
window.webContents.on('before-input-event', (event, input) => {
|
|
||||||
if (input.type !== 'keyDown') return
|
|
||||||
// meta = macOS Command / Windows 徽标键
|
|
||||||
if (!input.meta || input.control || input.alt) return
|
|
||||||
|
|
||||||
if (input.code === 'ArrowLeft') {
|
|
||||||
event.preventDefault()
|
|
||||||
sendPlayerControl('seek-backward')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (input.code === 'ArrowRight') {
|
|
||||||
event.preventDefault()
|
|
||||||
sendPlayerControl('seek-forward')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,7 +84,18 @@ const api = {
|
|||||||
},
|
},
|
||||||
dialog: {
|
dialog: {
|
||||||
openDir: (): Promise<string | null> => ipcRenderer.invoke(IPC.DIALOG_OPEN_DIR),
|
openDir: (): Promise<string | null> => ipcRenderer.invoke(IPC.DIALOG_OPEN_DIR),
|
||||||
openFile: (): Promise<string[] | null> => ipcRenderer.invoke(IPC.DIALOG_OPEN_FILE)
|
openFile: (): Promise<string[] | null> => ipcRenderer.invoke(IPC.DIALOG_OPEN_FILE),
|
||||||
|
saveText: (opts: {
|
||||||
|
defaultPath?: string
|
||||||
|
content: string
|
||||||
|
filters?: { name: string; extensions: string[] }[]
|
||||||
|
}): Promise<{ ok: boolean; canceled?: boolean; path?: string; error?: string }> =>
|
||||||
|
ipcRenderer.invoke(IPC.DIALOG_SAVE_TEXT, opts)
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
/** 在文件管理器中打开曲库内相对路径(仅本地文件服务可用) */
|
||||||
|
openLocal: (relPath: string): Promise<{ ok: boolean; error?: string }> =>
|
||||||
|
ipcRenderer.invoke(IPC.SHELL_OPEN_LOCAL, relPath)
|
||||||
},
|
},
|
||||||
lyric: {
|
lyric: {
|
||||||
toggle: (show: boolean) => ipcRenderer.invoke(IPC.LYRIC_TOGGLE, show),
|
toggle: (show: boolean) => ipcRenderer.invoke(IPC.LYRIC_TOGGLE, show),
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ import DesktopLyric from './pages/DesktopLyric'
|
|||||||
import { useUIStore } from './stores/uiStore'
|
import { useUIStore } from './stores/uiStore'
|
||||||
import { usePlayerStore } from './stores/playerStore'
|
import { usePlayerStore } from './stores/playerStore'
|
||||||
|
|
||||||
|
function GlobalToast(): JSX.Element | null {
|
||||||
|
const toast = useUIStore((s) => s.toast)
|
||||||
|
if (!toast) return null
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed bottom-24 left-1/2 z-[60] max-w-[min(92vw,28rem)] -translate-x-1/2 rounded-xl bg-[var(--fill-primary)] px-4 py-2.5 text-center text-sm text-[var(--text-primary)] shadow-lg ring-1 ring-black/10 dark:ring-white/10">
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type LyricLocationState = { background?: Location }
|
type LyricLocationState = { background?: Location }
|
||||||
|
|
||||||
/** 歌词页作为叠层时,下层 Routes 使用的 location(保持原页面挂载,避免退出闪烁) */
|
/** 歌词页作为叠层时,下层 Routes 使用的 location(保持原页面挂载,避免退出闪烁) */
|
||||||
@@ -94,9 +104,21 @@ export default function App(): JSX.Element {
|
|||||||
// 仅本地来源时自动起文件服务;远程模式下起服务会与「保存并启用」停服意图冲突
|
// 仅本地来源时自动起文件服务;远程模式下起服务会与「保存并启用」停服意图冲突
|
||||||
if (root && mediaSource === 'local') {
|
if (root && mediaSource === 'local') {
|
||||||
try {
|
try {
|
||||||
await window.api.http.start(root, port)
|
const status = await window.api.http.start(root, port)
|
||||||
|
if (status.running && status.port && status.port !== port) {
|
||||||
|
useUIStore
|
||||||
|
.getState()
|
||||||
|
.showToast(
|
||||||
|
`端口 ${port} 不可用(可能被占用),已改用 ${status.port}。可到设置中查看或更换。`
|
||||||
|
)
|
||||||
|
} else if (!status.running) {
|
||||||
|
useUIStore.getState().showToast('本地文件服务启动失败,歌曲可能无法播放')
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[boot] http.start failed', e)
|
console.error('[boot] http.start failed', e)
|
||||||
|
useUIStore
|
||||||
|
.getState()
|
||||||
|
.showToast('本地文件服务启动失败(端口可能被占用),歌曲可能无法播放')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await initPlayer()
|
await initPlayer()
|
||||||
@@ -122,9 +144,12 @@ export default function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/desktop-lyric" element={<DesktopLyric />} />
|
<Route path="/desktop-lyric" element={<DesktopLyric />} />
|
||||||
<Route path="*" element={<MainRoutes />} />
|
<Route path="*" element={<MainRoutes />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<GlobalToast />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function Layout(): JSX.Element {
|
|||||||
const isTransfer = pathname.startsWith('/transfer')
|
const isTransfer = pathname.startsWith('/transfer')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="relative flex h-full w-full flex-col">
|
||||||
<LyricEngine />
|
<LyricEngine />
|
||||||
{/* 上半:侧栏 + 内容 */}
|
{/* 上半:侧栏 + 内容 */}
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
|
||||||
import { Minus, Square, X, Search } from 'lucide-react'
|
import { Minus, Square, X, Search } from 'lucide-react'
|
||||||
|
|
||||||
/** 顶部标题栏:可拖拽 + 全局搜索 + Windows 窗口控制(Apple 风格极简) */
|
/** 顶部标题栏:可拖拽 + 全局搜索 + Windows 窗口控制 */
|
||||||
export default function TitleBar(): JSX.Element {
|
export default function TitleBar(): JSX.Element {
|
||||||
const isMac = navigator.platform.toLowerCase().includes('mac')
|
const isMac = navigator.platform.toLowerCase().includes('mac')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|||||||
@@ -1,26 +1,62 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { ChevronLeft, Play, Disc3 } from 'lucide-react'
|
import { ChevronLeft, FolderOpen, Play, Disc3 } from 'lucide-react'
|
||||||
import type { Album, Music } from '@shared/models'
|
import type { Album, Music } from '@shared/models'
|
||||||
import { Repo, buildFileUrl } from '../lib/repository'
|
import { Repo, buildFileUrl } from '../lib/repository'
|
||||||
import { useLibraryStore } from '../stores/libraryStore'
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
import { usePlayerStore } from '../stores/playerStore'
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
|
import { useUIStore } from '../stores/uiStore'
|
||||||
import MusicList from '../components/MusicList'
|
import MusicList from '../components/MusicList'
|
||||||
|
|
||||||
|
/** 专辑在曲库中的相对目录(优先 album.baseUrl,否则用首曲所在目录) */
|
||||||
|
function albumRelDir(album: Album | undefined, songs: Music[]): string {
|
||||||
|
const fromAlbum = (album?.baseUrl || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
||||||
|
if (fromAlbum) return fromAlbum
|
||||||
|
const m = songs[0]
|
||||||
|
if (!m) return ''
|
||||||
|
const base = (m.baseUrl || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
||||||
|
const musicPath = (m.musicPath || '').replace(/\\/g, '/')
|
||||||
|
const dir = musicPath.includes('/') ? musicPath.slice(0, musicPath.lastIndexOf('/')) : ''
|
||||||
|
return [base, dir].filter(Boolean).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
export default function AlbumDetail(): JSX.Element {
|
export default function AlbumDetail(): JSX.Element {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const albums = useLibraryStore((s) => s.albums)
|
const albums = useLibraryStore((s) => s.albums)
|
||||||
const httpBase = usePlayerStore((s) => s.httpBase)
|
const httpBase = usePlayerStore((s) => s.httpBase)
|
||||||
const playList = usePlayerStore((s) => s.playList)
|
const playList = usePlayerStore((s) => s.playList)
|
||||||
|
const showToast = useUIStore((s) => s.showToast)
|
||||||
const [songs, setSongs] = useState<Music[]>([])
|
const [songs, setSongs] = useState<Music[]>([])
|
||||||
|
const [localReady, setLocalReady] = useState(false)
|
||||||
|
const [opening, setOpening] = useState(false)
|
||||||
|
|
||||||
const album: Album | undefined = albums.find((a) => a.albumUId === id)
|
const album: Album | undefined = albums.find((a) => a.albumUId === id)
|
||||||
|
const relDir = useMemo(() => albumRelDir(album, songs), [album, songs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) Repo.musicByAlbum(id).then(setSongs)
|
if (id) Repo.musicByAlbum(id).then(setSongs)
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
// 仅本地文件服务运行时显示「打开目录」
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
|
const remoteUrl = (await window.api.store.get<string>('url')) || ''
|
||||||
|
const mediaSource =
|
||||||
|
(await window.api.store.get<'local' | 'remote'>('mediaSource')) ||
|
||||||
|
(remoteUrl.trim() ? 'remote' : 'local')
|
||||||
|
const status = await window.api.http.status()
|
||||||
|
if (!cancelled) {
|
||||||
|
setLocalReady(mediaSource === 'local' && status.running && !!status.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void refresh()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [httpBase])
|
||||||
|
|
||||||
// 专辑 cover 已是根相对完整路径;无封面时回退首曲
|
// 专辑 cover 已是根相对完整路径;无封面时回退首曲
|
||||||
const cover = album
|
const cover = album
|
||||||
? songs[0]
|
? songs[0]
|
||||||
@@ -30,6 +66,20 @@ export default function AlbumDetail(): JSX.Element {
|
|||||||
: ''
|
: ''
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
|
const openAlbumFolder = async (): Promise<void> => {
|
||||||
|
if (!relDir || opening) return
|
||||||
|
setOpening(true)
|
||||||
|
try {
|
||||||
|
const r = await window.api.shell.openLocal(relDir)
|
||||||
|
if (!r.ok) showToast(r.error || '无法打开目录')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
showToast('无法打开目录')
|
||||||
|
} finally {
|
||||||
|
setOpening(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -56,13 +106,24 @@ export default function AlbumDetail(): JSX.Element {
|
|||||||
<p className="text-sm text-[var(--text-secondary)]">
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
{album?.group} · {songs.length} 首歌曲
|
{album?.group} · {songs.length} 首歌曲
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => songs.length && playList(songs, 0)}
|
onClick={() => songs.length && playList(songs, 0)}
|
||||||
className="apple-btn-primary"
|
className="apple-btn-primary"
|
||||||
>
|
>
|
||||||
<Play size={16} className="fill-current" /> 播放全部
|
<Play size={16} className="fill-current" /> 播放全部
|
||||||
</button>
|
</button>
|
||||||
|
{localReady && relDir ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openAlbumFolder()}
|
||||||
|
disabled={opening}
|
||||||
|
title="在文件管理器中打开专辑目录"
|
||||||
|
className="apple-btn-ghost disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FolderOpen size={16} /> 打开目录
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
|||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
import { useLocation, useNavigate, type Location } from 'react-router-dom'
|
import { useLocation, useNavigate, type Location } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ChevronDown, Heart } from 'lucide-react'
|
import { ChevronDown, Download, Heart } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { usePlayerStore } from '../stores/playerStore'
|
import { usePlayerStore } from '../stores/playerStore'
|
||||||
import { buildFileUrl } from '../lib/repository'
|
import { buildFileUrl, Repo } from '../lib/repository'
|
||||||
import { useLibraryStore } from '../stores/libraryStore'
|
import { useLibraryStore } from '../stores/libraryStore'
|
||||||
import { useLyricStore } from '../stores/lyricStore'
|
import { useLyricStore } from '../stores/lyricStore'
|
||||||
|
import { useUIStore } from '../stores/uiStore'
|
||||||
|
|
||||||
/** Apple Music 风格切行:偏软的弹簧 */
|
/** 歌词切行:偏软的弹簧 */
|
||||||
const LINE_SPRING = { type: 'spring' as const, stiffness: 140, damping: 22, mass: 0.85 }
|
const LINE_SPRING = { type: 'spring' as const, stiffness: 140, damping: 22, mass: 0.85 }
|
||||||
const SIZE_SPRING = { type: 'spring' as const, stiffness: 160, damping: 24, mass: 0.8 }
|
const SIZE_SPRING = { type: 'spring' as const, stiffness: 160, damping: 24, mass: 0.8 }
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ function lineVisual(dist: number, active: boolean): {
|
|||||||
return { opacity: 0.14, scale: 0.9, blur: 1.4, y: 6, jpSize: 22, subSize: 13 }
|
return { opacity: 0.14, scale: 0.9, blur: 1.4, y: 6, jpSize: 22, subSize: 13 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 跟唱滚动:ease-out,接近 Apple 切行时的列表滑动感 */
|
/** 跟唱滚动:ease-out 缓动 */
|
||||||
function animateScrollTo(el: HTMLElement, to: number, duration = 580): () => void {
|
function animateScrollTo(el: HTMLElement, to: number, duration = 580): () => void {
|
||||||
const from = el.scrollTop
|
const from = el.scrollTop
|
||||||
const delta = to - from
|
const delta = to - from
|
||||||
@@ -69,6 +70,11 @@ export default function Lyric(): JSX.Element {
|
|||||||
const musicMap = useLibraryStore((s) => s.music)
|
const musicMap = useLibraryStore((s) => s.music)
|
||||||
const [showZh, setShowZh] = useState(true)
|
const [showZh, setShowZh] = useState(true)
|
||||||
const [showRoma, setShowRoma] = useState(true)
|
const [showRoma, setShowRoma] = useState(true)
|
||||||
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
|
const [exportBusy, setExportBusy] = useState(false)
|
||||||
|
const [lyricAvail, setLyricAvail] = useState({ jp: false, zh: false, roma: false })
|
||||||
|
const exportMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const showToast = useUIStore((s) => s.showToast)
|
||||||
const [leaving, setLeaving] = useState(false)
|
const [leaving, setLeaving] = useState(false)
|
||||||
/** 入场动画结束后去掉 transform,否则 Electron 不认 -webkit-app-region: drag */
|
/** 入场动画结束后去掉 transform,否则 Electron 不认 -webkit-app-region: drag */
|
||||||
const [sheetSettled, setSheetSettled] = useState(false)
|
const [sheetSettled, setSheetSettled] = useState(false)
|
||||||
@@ -115,6 +121,36 @@ export default function Lyric(): JSX.Element {
|
|||||||
load(full)
|
load(full)
|
||||||
}, [current, musicMap, load])
|
}, [current, musicMap, load])
|
||||||
|
|
||||||
|
// 导出菜单:同步各语种是否有缓存歌词
|
||||||
|
useEffect(() => {
|
||||||
|
setExportOpen(false)
|
||||||
|
if (!current) {
|
||||||
|
setLyricAvail({ jp: false, zh: false, roma: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
void Repo.lyric(current.musicUId).then((ly) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setLyricAvail({
|
||||||
|
jp: !!(ly?.lyricJp && ly.lyricJp.trim()),
|
||||||
|
zh: !!(ly?.lyricZh && ly.lyricZh.trim()),
|
||||||
|
roma: !!(ly?.lyricRoma && ly.lyricRoma.trim())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [current?.musicUId, lines.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!exportOpen) return
|
||||||
|
const onDown = (e: MouseEvent): void => {
|
||||||
|
if (!exportMenuRef.current?.contains(e.target as Node)) setExportOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [exportOpen])
|
||||||
|
|
||||||
const activeIdx = useMemo(() => {
|
const activeIdx = useMemo(() => {
|
||||||
let idx = -1
|
let idx = -1
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
@@ -320,6 +356,40 @@ export default function Lyric(): JSX.Element {
|
|||||||
play()
|
play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizeFileBase = (name: string): string =>
|
||||||
|
name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'lyric'
|
||||||
|
|
||||||
|
const exportLang = async (lang: 'jp' | 'zh' | 'roma'): Promise<void> => {
|
||||||
|
if (!current || exportBusy) return
|
||||||
|
setExportBusy(true)
|
||||||
|
setExportOpen(false)
|
||||||
|
try {
|
||||||
|
const ly = await Repo.lyric(current.musicUId)
|
||||||
|
const content =
|
||||||
|
lang === 'jp' ? ly?.lyricJp : lang === 'zh' ? ly?.lyricZh : ly?.lyricRoma
|
||||||
|
if (!content?.trim()) {
|
||||||
|
showToast('该语种暂无歌词可导出')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const base = sanitizeFileBase(current.musicName || 'lyric')
|
||||||
|
const defaultPath =
|
||||||
|
lang === 'jp' ? `${base}.lrc` : lang === 'zh' ? `${base}.zh.lrc` : `${base}.roma.lrc`
|
||||||
|
const result = await window.api.dialog.saveText({
|
||||||
|
defaultPath,
|
||||||
|
content,
|
||||||
|
filters: [{ name: 'LRC', extensions: ['lrc'] }]
|
||||||
|
})
|
||||||
|
if (result.canceled) return
|
||||||
|
if (result.ok) showToast('歌词已导出')
|
||||||
|
else showToast(result.error ? `导出失败:${result.error}` : '导出失败')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
showToast('导出失败')
|
||||||
|
} finally {
|
||||||
|
setExportBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const langToggles = (
|
const langToggles = (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -352,6 +422,43 @@ export default function Lyric(): JSX.Element {
|
|||||||
>
|
>
|
||||||
中文
|
中文
|
||||||
</button>
|
</button>
|
||||||
|
<div ref={exportMenuRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!current || exportBusy || (!lyricAvail.jp && !lyricAvail.zh && !lyricAvail.roma)}
|
||||||
|
onClick={() => setExportOpen((v) => !v)}
|
||||||
|
title="导出歌词"
|
||||||
|
className={clsx(
|
||||||
|
'no-drag flex h-8 items-center gap-1 rounded-full px-3 text-xs font-medium transition-colors',
|
||||||
|
'bg-black/5 text-[var(--text-secondary)] hover:text-[var(--text)] dark:bg-white/10',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
导出歌词
|
||||||
|
</button>
|
||||||
|
{exportOpen && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-2 w-max overflow-hidden rounded-xl border border-[var(--separator)] bg-[var(--bg)] py-1 shadow-apple-lg">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: 'jp' as const, label: '日文歌词', ok: lyricAvail.jp },
|
||||||
|
{ key: 'zh' as const, label: '中文歌词', ok: lyricAvail.zh },
|
||||||
|
{ key: 'roma' as const, label: '罗马音歌词', ok: lyricAvail.roma }
|
||||||
|
] as const
|
||||||
|
).map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
disabled={!item.ok || exportBusy}
|
||||||
|
onClick={() => void exportLang(item.key)}
|
||||||
|
className="flex w-full px-3.5 py-2 text-left text-xs text-[var(--text)] hover:bg-black/5 disabled:opacity-35 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export default function Settings(): JSX.Element {
|
|||||||
|
|
||||||
const showPortFallbackWarn = (requested: number, actual: number): void => {
|
const showPortFallbackWarn = (requested: number, actual: number): void => {
|
||||||
if (!actual || actual === requested) return
|
if (!actual || actual === requested) return
|
||||||
|
// 输入框同步为实际端口,避免仍显示已被占用的偏好值
|
||||||
|
setPort(actual)
|
||||||
showSourceWarn(
|
showSourceWarn(
|
||||||
`端口 ${requested} 不可用(可能被占用),已改用 ${actual}。可关闭占用进程后重新「应用并启动」,或继续使用当前端口。`
|
`端口 ${requested} 不可用(可能被占用),已改用 ${actual}。可关闭占用进程后重新「应用并启动」,或继续使用当前端口。`
|
||||||
)
|
)
|
||||||
@@ -377,7 +379,9 @@ export default function Settings(): JSX.Element {
|
|||||||
<p className="mt-3 text-xs leading-relaxed text-[var(--text-secondary)]">
|
<p className="mt-3 text-xs leading-relaxed text-[var(--text-secondary)]">
|
||||||
选择包含{' '}
|
选择包含{' '}
|
||||||
<code className="rounded bg-black/10 px-1 dark:bg-white/10">LoveLive</code>{' '}
|
<code className="rounded bg-black/10 px-1 dark:bg-white/10">LoveLive</code>{' '}
|
||||||
的目录(也可直接选中 LoveLive)。点击「应用并启动」后才会改用本地并停用远程。
|
的目录(也可直接选中 LoveLive)。点击「
|
||||||
|
{serverStatus.running ? '应用并重启' : '应用并启动'}
|
||||||
|
」后才会改用本地并停用远程。
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -519,7 +523,7 @@ export default function Settings(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mt-4 text-sm text-[var(--text-secondary)]">
|
<p className="mt-4 text-sm text-[var(--text-secondary)]">
|
||||||
LoveLiveMusicPlayer 桌面端 · Apple 风格重构版
|
LoveLiveMusicPlayer 桌面端
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
Electron + Vite + React · 与移动端共享协议层(transVer 1)
|
Electron + Vite + React · 与移动端共享协议层(transVer 1)
|
||||||
|
|||||||
@@ -239,6 +239,22 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 与原项目一致:窗口内 ← / → 快退/快进 5 秒(全平台,无需修饰键)
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) return
|
||||||
|
const tag = (event.target as HTMLElement | null)?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || (event.target as HTMLElement)?.isContentEditable)
|
||||||
|
return
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
get().seek(Math.max(0, get().progress - 5))
|
||||||
|
} else {
|
||||||
|
const { progress, duration } = get()
|
||||||
|
const max = Number.isFinite(duration) && duration > 0 ? duration : progress + 5
|
||||||
|
get().seek(Math.min(max, progress + 5))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', () => get().next())
|
navigator.mediaSession.setActionHandler('nexttrack', () => get().next())
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', () => get().prev())
|
navigator.mediaSession.setActionHandler('previoustrack', () => get().prev())
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ interface UIState {
|
|||||||
theme: ThemeMode
|
theme: ThemeMode
|
||||||
accent: string
|
accent: string
|
||||||
isDark: boolean
|
isDark: boolean
|
||||||
|
/** 全局轻提示(端口回退等) */
|
||||||
|
toast: string
|
||||||
setTheme: (t: ThemeMode) => void
|
setTheme: (t: ThemeMode) => void
|
||||||
setAccent: (c: string) => void
|
setAccent: (c: string) => void
|
||||||
|
showToast: (msg: string, ms?: number) => void
|
||||||
init: () => Promise<void>
|
init: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
function applyTheme(theme: ThemeMode): boolean {
|
function applyTheme(theme: ThemeMode): boolean {
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
const isDark = theme === 'dark' || (theme === 'system' && prefersDark)
|
const isDark = theme === 'dark' || (theme === 'system' && prefersDark)
|
||||||
@@ -36,6 +41,7 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
theme: 'system',
|
theme: 'system',
|
||||||
accent: '#0A84FF',
|
accent: '#0A84FF',
|
||||||
isDark: false,
|
isDark: false,
|
||||||
|
toast: '',
|
||||||
setTheme: (t) => {
|
setTheme: (t) => {
|
||||||
const isDark = applyTheme(t)
|
const isDark = applyTheme(t)
|
||||||
window.api.store.set('theme', t)
|
window.api.store.set('theme', t)
|
||||||
@@ -46,6 +52,15 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
window.api.store.set('accentColor', c)
|
window.api.store.set('accentColor', c)
|
||||||
set({ accent: c })
|
set({ accent: c })
|
||||||
},
|
},
|
||||||
|
showToast: (msg, ms = 6000) => {
|
||||||
|
if (!msg) return
|
||||||
|
if (toastTimer) clearTimeout(toastTimer)
|
||||||
|
set({ toast: msg })
|
||||||
|
toastTimer = setTimeout(() => {
|
||||||
|
set({ toast: '' })
|
||||||
|
toastTimer = null
|
||||||
|
}, ms)
|
||||||
|
},
|
||||||
init: async () => {
|
init: async () => {
|
||||||
const theme = (await window.api.store.get<ThemeMode>('theme')) || 'system'
|
const theme = (await window.api.store.get<ThemeMode>('theme')) || 'system'
|
||||||
const accent = (await window.api.store.get<string>('accentColor')) || '#0A84FF'
|
const accent = (await window.api.store.get<string>('accentColor')) || '#0A84FF'
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ html.desktop-lyric #root {
|
|||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Apple 毛玻璃 */
|
/* 毛玻璃 */
|
||||||
.glass {
|
.glass {
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
backdrop-filter: saturate(180%) blur(30px);
|
backdrop-filter: saturate(180%) blur(30px);
|
||||||
@@ -134,7 +134,7 @@ html.desktop-lyric #root {
|
|||||||
.vinyl-spin {
|
.vinyl-spin {
|
||||||
animation: vinyl-spin 20s linear infinite;
|
animation: vinyl-spin 20s linear infinite;
|
||||||
}
|
}
|
||||||
/* 歌词页上下羽化,接近 Apple Music 跟唱视口 */
|
/* 歌词页上下羽化 */
|
||||||
.lyric-mask {
|
.lyric-mask {
|
||||||
mask-image: linear-gradient(
|
mask-image: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export const IPC = {
|
|||||||
// ---- 对话框/文件系统 ----
|
// ---- 对话框/文件系统 ----
|
||||||
DIALOG_OPEN_DIR: 'dialog:openDir',
|
DIALOG_OPEN_DIR: 'dialog:openDir',
|
||||||
DIALOG_OPEN_FILE: 'dialog:openFile',
|
DIALOG_OPEN_FILE: 'dialog:openFile',
|
||||||
|
/** 另存为文本文件(歌词 LRC 等) */
|
||||||
|
DIALOG_SAVE_TEXT: 'dialog:saveText',
|
||||||
|
/** 在文件管理器中打开本地曲库相对路径(须落在文件服务根目录内) */
|
||||||
|
SHELL_OPEN_LOCAL: 'shell:openLocal',
|
||||||
FS_EXPORT_EXCEL: 'fs:exportExcel',
|
FS_EXPORT_EXCEL: 'fs:exportExcel',
|
||||||
|
|
||||||
// ---- 窗口控制 ----
|
// ---- 窗口控制 ----
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export default {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
// Apple 系统色板
|
|
||||||
apple: {
|
apple: {
|
||||||
// 主强调色跟随用户设置(--accent-rgb),支持 /透明度 语法
|
// 主强调色跟随用户设置(--accent-rgb),支持 /透明度 语法
|
||||||
blue: 'rgb(var(--accent-rgb) / <alpha-value>)',
|
blue: 'rgb(var(--accent-rgb) / <alpha-value>)',
|
||||||
|
|||||||
Reference in New Issue
Block a user