From 9455c1d57e4e129750bd3a5b97783d4321219640 Mon Sep 17 00:00:00 2001 From: zhushenwudi <55681140@163.com> Date: Sat, 8 Aug 2026 23:41:19 +0800 Subject: [PATCH] adapt windows --- README.md | 11 +-- electron.vite.config.ts | 19 +++- package.json | 6 +- scripts/run-electron-vite.mjs | 63 +++++++++++++ src/main/index.ts | 10 +- src/main/ipc/index.ts | 51 ++++++++++ src/main/services/fileServer.ts | 41 ++++---- src/main/services/mediaShortcuts.ts | 30 +----- src/preload/index.ts | 13 ++- src/renderer/src/App.tsx | 35 ++++++- src/renderer/src/components/Layout.tsx | 2 +- src/renderer/src/components/TitleBar.tsx | 2 +- src/renderer/src/pages/AlbumDetail.tsx | 67 ++++++++++++- src/renderer/src/pages/Lyric.tsx | 115 ++++++++++++++++++++++- src/renderer/src/pages/Settings.tsx | 8 +- src/renderer/src/stores/playerStore.ts | 16 ++++ src/renderer/src/stores/uiStore.ts | 15 +++ src/renderer/src/styles/index.css | 4 +- src/shared/ipc/channels.ts | 4 + tailwind.config.js | 1 - 20 files changed, 433 insertions(+), 80 deletions(-) create mode 100644 scripts/run-electron-vite.mjs diff --git a/README.md b/README.md index 3e94f15..9d3bd85 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ -# LoveLiveMusicPlayer · 桌面端(Apple 风格重构版) +# LoveLiveMusicPlayer · 桌面端 -基于最新 **electron-vite** 脚手架重构的 LoveLive! 音乐播放器 PC 端,复刻原 Electron 项目全部功能, -UI 采用 **Apple / macOS 风格**(毛玻璃、圆角、SF 字体、系统色板、深浅色)。 +基于 **electron-vite** 的 LoveLive! 音乐播放器 PC 端,复刻原 Electron 项目功能。 -> 设计目标之一:**为后续移动端(Flutter)重构预留最大兼容性与可扩展性**。 +> 设计目标之一:**为后续移动端(Flutter)预留最大兼容性与可扩展性**。 > 双端联动协议被抽取为独立、框架无关的共享模块 `src/shared/protocol`,移动端可直接对照复用。 ## 技术栈 @@ -13,7 +12,7 @@ UI 采用 **Apple / macOS 风格**(毛玻璃、圆角、SF 字体、系统色 | 框架 | Electron 43 + Vite 7 + React 19 + TypeScript | | 脚手架 | electron-vite | | 状态管理 | Zustand | -| 样式 | Tailwind CSS 3 + 自研 Apple 设计系统(`src/renderer/src/styles/index.css`) | +| 样式 | Tailwind CSS 3(`src/renderer/src/styles/index.css`) | | 本地数据库 | better-sqlite3(表结构与移动端 SQLite 对齐) | | 配置存储 | electron-store | | 局域网通信 | ws(WebSocket 服务端)+ express(HTTP 文件服务),**运行在主进程** | @@ -56,7 +55,7 @@ src/ - 本地 HTTP 文件服务(端口探测、可配置) - iOS flac→wav 转码 - 设置:曲库目录、HTTP 端口、主题(深/浅/跟随系统)、强调色 -- 无边框窗口 + Apple 质感(Windows 自定义控制按钮 / macOS 红绿灯位) +- 无边框窗口(Windows 自定义控制按钮 / macOS 红绿灯位) ## 与移动端的兼容性设计 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 22cdb3d..1e2d9a7 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -2,6 +2,16 @@ import { resolve } from 'path' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 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({ main: { plugins: [externalizeDepsPlugin()], @@ -22,9 +32,16 @@ export default defineConfig({ }, renderer: { // 绑定 IPv4,避免仅监听 ::1 导致 Electron 用 localhost(127.0.0.1) 连接被拒 + // 注意:不要再写 hmr.port=5173,会另起 WS 占口导致热更新假连上、不推送 server: { host: '127.0.0.1', - port: 5173 + port: 5173, + strictPort: true, + ...(watchOptions ? { watch: watchOptions } : {}), + hmr: { + protocol: 'ws', + host: '127.0.0.1' + } }, resolve: { alias: { diff --git a/package.json b/package.json index f83e7c8..829a9d7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lovelive-music-player-next", "version": "2.0.0", - "description": "LoveLiveMusicPlayer 桌面端(Apple 风格重构版)", + "description": "LoveLiveMusicPlayer 桌面端", "main": "./out/main/index.js", "author": "zhushenwudi", "license": "MIT", @@ -11,8 +11,8 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "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", - "dev": "node scripts/patch-electron-dev-name.mjs && env -u ELECTRON_RUN_AS_NODE -u ATOM_SHELL_INTERNAL_RUN_AS_NODE electron-vite dev", + "start": "node scripts/run-electron-vite.mjs preview", + "dev": "node scripts/patch-electron-dev-name.mjs && node scripts/run-electron-vite.mjs dev", "build": "npm run typecheck && electron-vite build", "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", diff --git a/scripts/run-electron-vite.mjs b/scripts/run-electron-vite.mjs new file mode 100644 index 0000000..592a119 --- /dev/null +++ b/scripts/run-electron-vite.mjs @@ -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) +}) diff --git a/src/main/index.ts b/src/main/index.ts index 6ba0e0c..ff59094 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,11 +10,7 @@ import { initDatabase } from './services/database' import { stopFileServer } from './services/fileServer' import { stopMusicServer } from './services/musicServer' import { stopDataServer } from './services/dataServer' -import { - bindSeekShortcuts, - registerMediaShortcuts, - unregisterMediaShortcuts -} from './services/mediaShortcuts' +import { registerMediaShortcuts, unregisterMediaShortcuts } from './services/mediaShortcuts' /** 开发态仍运行 Electron.app,需手动改 Dock 图标;菜单栏名称仍会显示 Electron(Info.plist 限制) */ function applyDevBranding(): void { @@ -74,15 +70,13 @@ if (!gotLock) { input.code === 'Numpad0' if (zoomKey) event.preventDefault() }) - // ⌘/Win + ←/→ 快退/快进 5s(仅聚焦时) - bindSeekShortcuts(window) }) initDatabase() registerIpc() createMainWindow() createTray() - // ⌘/Win + P 播放/暂停(全局,托盘后台也可用) + // Ctrl/⌘ + P 播放/暂停(全局,托盘后台也可用;与原项目一致) registerMediaShortcuts() app.on('activate', () => { diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 5ca5171..8962aba 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,4 +1,7 @@ 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 { getStore } from '../services/store' import { dbQuery, dbExec } from '../services/database' @@ -133,6 +136,54 @@ export function registerIpc(): void { const r = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] }) 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) => { diff --git a/src/main/services/fileServer.ts b/src/main/services/fileServer.ts index 43e774c..9e5a9aa 100644 --- a/src/main/services/fileServer.ts +++ b/src/main/services/fileServer.ts @@ -44,25 +44,34 @@ function enqueue(fn: () => Promise): Promise { return next } -/** 探测可用端口(占用则 +1,直到上限) */ -function findAvailablePort(start: number): Promise { - return new Promise((resolve, reject) => { - const tryPort = (port: number): void => { - if (port > HTTP_PORT_RANGE.MAX) { - reject(new Error('没有可用端口')) - return - } - const tester = net.createServer() - tester.once('error', () => tryPort(port + 1)) - tester.once('listening', () => { - tester.close(() => resolve(port)) - }) - tester.listen(port, '0.0.0.0') - } - tryPort(start) +/** 尝试在指定 host 上绑定端口;成功则立刻释放 */ +function canBind(port: number, host: string): Promise { + return new Promise((resolve) => { + const tester = net.createServer() + tester.once('error', () => resolve(false)) + tester.once('listening', () => { + tester.close(() => resolve(true)) + }) + tester.listen(port, host) }) } +/** + * 探测可用端口(占用则 +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 { + 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 { running: boolean port: number diff --git a/src/main/services/mediaShortcuts.ts b/src/main/services/mediaShortcuts.ts index f237eb0..ad5312f 100644 --- a/src/main/services/mediaShortcuts.ts +++ b/src/main/services/mediaShortcuts.ts @@ -1,16 +1,13 @@ -import { globalShortcut, type BrowserWindow } from 'electron' +import { globalShortcut } from 'electron' import { sendPlayerControl } from './playerControl' /** * 媒体快捷键: - * - 播放/暂停:全局 Super+P(macOS ⌘P / Windows Win+P),托盘后台也可用 - * - 快退/快进:仅窗口聚焦时 Super+← / Super+→,避免全局抢占方向键 - * - * Electron 的 Super = Windows 徽标键 / macOS Command。 - * 注意:Windows 系统常占用 Win+P(投影),注册失败时会打日志。 + * - 播放/暂停:全局 CommandOrControl+P(macOS ⌘P / Windows Ctrl+P),与原项目一致 + * - 快退/快进:由渲染进程监听 ← / →(与原项目一致,见 playerStore) */ -const GLOBAL_PLAY_PAUSE = 'Super+P' +const GLOBAL_PLAY_PAUSE = 'CommandOrControl+P' export function registerMediaShortcuts(): void { try { @@ -32,22 +29,3 @@ export function unregisterMediaShortcuts(): void { /* 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') - } - }) -} diff --git a/src/preload/index.ts b/src/preload/index.ts index acda01a..5b4e9e6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -84,7 +84,18 @@ const api = { }, dialog: { openDir: (): Promise => ipcRenderer.invoke(IPC.DIALOG_OPEN_DIR), - openFile: (): Promise => ipcRenderer.invoke(IPC.DIALOG_OPEN_FILE) + openFile: (): Promise => 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: { toggle: (show: boolean) => ipcRenderer.invoke(IPC.LYRIC_TOGGLE, show), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index be7623a..50f394a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -15,6 +15,16 @@ import DesktopLyric from './pages/DesktopLyric' import { useUIStore } from './stores/uiStore' import { usePlayerStore } from './stores/playerStore' +function GlobalToast(): JSX.Element | null { + const toast = useUIStore((s) => s.toast) + if (!toast) return null + return ( +
+ {toast} +
+ ) +} + type LyricLocationState = { background?: Location } /** 歌词页作为叠层时,下层 Routes 使用的 location(保持原页面挂载,避免退出闪烁) */ @@ -94,9 +104,21 @@ export default function App(): JSX.Element { // 仅本地来源时自动起文件服务;远程模式下起服务会与「保存并启用」停服意图冲突 if (root && mediaSource === 'local') { 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) { console.error('[boot] http.start failed', e) + useUIStore + .getState() + .showToast('本地文件服务启动失败(端口可能被占用),歌曲可能无法播放') } } await initPlayer() @@ -122,9 +144,12 @@ export default function App(): JSX.Element { } return ( - - } /> - } /> - + <> + + } /> + } /> + + + ) } diff --git a/src/renderer/src/components/Layout.tsx b/src/renderer/src/components/Layout.tsx index 5490f4a..14bac69 100644 --- a/src/renderer/src/components/Layout.tsx +++ b/src/renderer/src/components/Layout.tsx @@ -10,7 +10,7 @@ export default function Layout(): JSX.Element { const isTransfer = pathname.startsWith('/transfer') return ( -
+
{/* 上半:侧栏 + 内容 */}
diff --git a/src/renderer/src/components/TitleBar.tsx b/src/renderer/src/components/TitleBar.tsx index 149f650..76cce17 100644 --- a/src/renderer/src/components/TitleBar.tsx +++ b/src/renderer/src/components/TitleBar.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { useNavigate, useLocation, useSearchParams } from 'react-router-dom' import { Minus, Square, X, Search } from 'lucide-react' -/** 顶部标题栏:可拖拽 + 全局搜索 + Windows 窗口控制(Apple 风格极简) */ +/** 顶部标题栏:可拖拽 + 全局搜索 + Windows 窗口控制 */ export default function TitleBar(): JSX.Element { const isMac = navigator.platform.toLowerCase().includes('mac') const navigate = useNavigate() diff --git a/src/renderer/src/pages/AlbumDetail.tsx b/src/renderer/src/pages/AlbumDetail.tsx index fd75cc7..94fe2bd 100644 --- a/src/renderer/src/pages/AlbumDetail.tsx +++ b/src/renderer/src/pages/AlbumDetail.tsx @@ -1,26 +1,62 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' 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 { Repo, buildFileUrl } from '../lib/repository' import { useLibraryStore } from '../stores/libraryStore' import { usePlayerStore } from '../stores/playerStore' +import { useUIStore } from '../stores/uiStore' 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 { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const albums = useLibraryStore((s) => s.albums) const httpBase = usePlayerStore((s) => s.httpBase) const playList = usePlayerStore((s) => s.playList) + const showToast = useUIStore((s) => s.showToast) const [songs, setSongs] = useState([]) + const [localReady, setLocalReady] = useState(false) + const [opening, setOpening] = useState(false) const album: Album | undefined = albums.find((a) => a.albumUId === id) + const relDir = useMemo(() => albumRelDir(album, songs), [album, songs]) useEffect(() => { if (id) Repo.musicByAlbum(id).then(setSongs) }, [id]) + // 仅本地文件服务运行时显示「打开目录」 + useEffect(() => { + let cancelled = false + const refresh = async (): Promise => { + const remoteUrl = (await window.api.store.get('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 已是根相对完整路径;无封面时回退首曲 const cover = album ? songs[0] @@ -30,6 +66,20 @@ export default function AlbumDetail(): JSX.Element { : '' : '' + const openAlbumFolder = async (): Promise => { + 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 (
+ {localReady && relDir ? ( + + ) : null}
diff --git a/src/renderer/src/pages/Lyric.tsx b/src/renderer/src/pages/Lyric.tsx index 1d4689f..9888238 100644 --- a/src/renderer/src/pages/Lyric.tsx +++ b/src/renderer/src/pages/Lyric.tsx @@ -2,14 +2,15 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { flushSync } from 'react-dom' import { useLocation, useNavigate, type Location } from 'react-router-dom' import { motion } from 'framer-motion' -import { ChevronDown, Heart } from 'lucide-react' +import { ChevronDown, Download, Heart } from 'lucide-react' import clsx from 'clsx' import { usePlayerStore } from '../stores/playerStore' -import { buildFileUrl } from '../lib/repository' +import { buildFileUrl, Repo } from '../lib/repository' import { useLibraryStore } from '../stores/libraryStore' 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 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 } } -/** 跟唱滚动:ease-out,接近 Apple 切行时的列表滑动感 */ +/** 跟唱滚动:ease-out 缓动 */ function animateScrollTo(el: HTMLElement, to: number, duration = 580): () => void { const from = el.scrollTop const delta = to - from @@ -69,6 +70,11 @@ export default function Lyric(): JSX.Element { const musicMap = useLibraryStore((s) => s.music) const [showZh, setShowZh] = 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(null) + const showToast = useUIStore((s) => s.showToast) const [leaving, setLeaving] = useState(false) /** 入场动画结束后去掉 transform,否则 Electron 不认 -webkit-app-region: drag */ const [sheetSettled, setSheetSettled] = useState(false) @@ -115,6 +121,36 @@ export default function Lyric(): JSX.Element { load(full) }, [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(() => { let idx = -1 for (let i = 0; i < lines.length; i++) { @@ -320,6 +356,40 @@ export default function Lyric(): JSX.Element { play() } + const sanitizeFileBase = (name: string): string => + name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'lyric' + + const exportLang = async (lang: 'jp' | 'zh' | 'roma'): Promise => { + 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 = (
+
+ + {exportOpen && ( +
+ {( + [ + { 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) => ( + + ))} +
+ )} +
) diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 22b666b..ee5752e 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -82,6 +82,8 @@ export default function Settings(): JSX.Element { const showPortFallbackWarn = (requested: number, actual: number): void => { if (!actual || actual === requested) return + // 输入框同步为实际端口,避免仍显示已被占用的偏好值 + setPort(actual) showSourceWarn( `端口 ${requested} 不可用(可能被占用),已改用 ${actual}。可关闭占用进程后重新「应用并启动」,或继续使用当前端口。` ) @@ -377,7 +379,9 @@ export default function Settings(): JSX.Element {

选择包含{' '} LoveLive{' '} - 的目录(也可直接选中 LoveLive)。点击「应用并启动」后才会改用本地并停用远程。 + 的目录(也可直接选中 LoveLive)。点击「 + {serverStatus.running ? '应用并重启' : '应用并启动'} + 」后才会改用本地并停用远程。

) : ( @@ -519,7 +523,7 @@ export default function Settings(): JSX.Element { )}

- LoveLiveMusicPlayer 桌面端 · Apple 风格重构版 + LoveLiveMusicPlayer 桌面端

Electron + Vite + React · 与移动端共享协议层(transVer 1) diff --git a/src/renderer/src/stores/playerStore.ts b/src/renderer/src/stores/playerStore.ts index 9d9121d..af8533a 100644 --- a/src/renderer/src/stores/playerStore.ts +++ b/src/renderer/src/stores/playerStore.ts @@ -239,6 +239,22 @@ export const usePlayerStore = create((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) { navigator.mediaSession.setActionHandler('nexttrack', () => get().next()) navigator.mediaSession.setActionHandler('previoustrack', () => get().prev()) diff --git a/src/renderer/src/stores/uiStore.ts b/src/renderer/src/stores/uiStore.ts index 09b35e4..4fcfee7 100644 --- a/src/renderer/src/stores/uiStore.ts +++ b/src/renderer/src/stores/uiStore.ts @@ -6,11 +6,16 @@ interface UIState { theme: ThemeMode accent: string isDark: boolean + /** 全局轻提示(端口回退等) */ + toast: string setTheme: (t: ThemeMode) => void setAccent: (c: string) => void + showToast: (msg: string, ms?: number) => void init: () => Promise } +let toastTimer: ReturnType | null = null + function applyTheme(theme: ThemeMode): boolean { const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches const isDark = theme === 'dark' || (theme === 'system' && prefersDark) @@ -36,6 +41,7 @@ export const useUIStore = create((set) => ({ theme: 'system', accent: '#0A84FF', isDark: false, + toast: '', setTheme: (t) => { const isDark = applyTheme(t) window.api.store.set('theme', t) @@ -46,6 +52,15 @@ export const useUIStore = create((set) => ({ window.api.store.set('accentColor', 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 () => { const theme = (await window.api.store.get('theme')) || 'system' const accent = (await window.api.store.get('accentColor')) || '#0A84FF' diff --git a/src/renderer/src/styles/index.css b/src/renderer/src/styles/index.css index ee78c68..14fa698 100644 --- a/src/renderer/src/styles/index.css +++ b/src/renderer/src/styles/index.css @@ -67,7 +67,7 @@ html.desktop-lyric #root { -webkit-app-region: no-drag; } -/* Apple 毛玻璃 */ +/* 毛玻璃 */ .glass { background: var(--bg-elevated); backdrop-filter: saturate(180%) blur(30px); @@ -134,7 +134,7 @@ html.desktop-lyric #root { .vinyl-spin { animation: vinyl-spin 20s linear infinite; } -/* 歌词页上下羽化,接近 Apple Music 跟唱视口 */ +/* 歌词页上下羽化 */ .lyric-mask { mask-image: linear-gradient( to bottom, diff --git a/src/shared/ipc/channels.ts b/src/shared/ipc/channels.ts index ab3304f..10c443e 100644 --- a/src/shared/ipc/channels.ts +++ b/src/shared/ipc/channels.ts @@ -54,6 +54,10 @@ export const IPC = { // ---- 对话框/文件系统 ---- DIALOG_OPEN_DIR: 'dialog:openDir', DIALOG_OPEN_FILE: 'dialog:openFile', + /** 另存为文本文件(歌词 LRC 等) */ + DIALOG_SAVE_TEXT: 'dialog:saveText', + /** 在文件管理器中打开本地曲库相对路径(须落在文件服务根目录内) */ + SHELL_OPEN_LOCAL: 'shell:openLocal', FS_EXPORT_EXCEL: 'fs:exportExcel', // ---- 窗口控制 ---- diff --git a/tailwind.config.js b/tailwind.config.js index ec09bc2..a3cc4e5 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -18,7 +18,6 @@ export default { ] }, colors: { - // Apple 系统色板 apple: { // 主强调色跟随用户设置(--accent-rgb),支持 /透明度 语法 blue: 'rgb(var(--accent-rgb) / )',