adapt windows

This commit is contained in:
2026-08-08 23:41:19 +08:00
parent 1fa69aa055
commit 9455c1d57e
20 changed files with 433 additions and 80 deletions

View File

@@ -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 |
| 局域网通信 | wsWebSocket 服务端)+ expressHTTP 文件服务),**运行在主进程** |
@@ -56,7 +55,7 @@ src/
- 本地 HTTP 文件服务(端口探测、可配置)
- iOS flac→wav 转码
- 设置曲库目录、HTTP 端口、主题(深/浅/跟随系统)、强调色
- 无边框窗口 + Apple 质感Windows 自定义控制按钮 / macOS 红绿灯位)
- 无边框窗口Windows 自定义控制按钮 / macOS 红绿灯位)
## 与移动端的兼容性设计

View File

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

View File

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

View 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)
})

View File

@@ -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 图标;菜单栏名称仍会显示 ElectronInfo.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', () => {

View File

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

View File

@@ -44,23 +44,32 @@ function enqueue<T>(fn: () => Promise<T>): Promise<T> {
return next
}
/** 探测可用端口(占用则 +1直到上限 */
function findAvailablePort(start: number): Promise<number> {
return new Promise((resolve, reject) => {
const tryPort = (port: number): void => {
if (port > HTTP_PORT_RANGE.MAX) {
reject(new Error('没有可用端口'))
return
}
/** 尝试在指定 host 上绑定端口;成功则立刻释放 */
function canBind(port: number, host: string): Promise<boolean> {
return new Promise((resolve) => {
const tester = net.createServer()
tester.once('error', () => tryPort(port + 1))
tester.once('error', () => resolve(false))
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.0LAN与 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 {

View File

@@ -1,16 +1,13 @@
import { globalShortcut, type BrowserWindow } from 'electron'
import { globalShortcut } from 'electron'
import { sendPlayerControl } from './playerControl'
/**
* 媒体快捷键:
* - 播放/暂停:全局 Super+PmacOS ⌘P / Windows Win+P托盘后台也可用
* - 快退/快进:仅窗口聚焦时 Super+← / Super+→,避免全局抢占方向键
*
* Electron 的 Super = Windows 徽标键 / macOS Command。
* 注意Windows 系统常占用 Win+P投影注册失败时会打日志。
* - 播放/暂停:全局 CommandOrControl+PmacOS ⌘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')
}
})
}

View File

@@ -84,7 +84,18 @@ const api = {
},
dialog: {
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: {
toggle: (show: boolean) => ipcRenderer.invoke(IPC.LYRIC_TOGGLE, show),

View File

@@ -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 (
<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 }
/** 歌词页作为叠层时,下层 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 (
<>
<Routes>
<Route path="/desktop-lyric" element={<DesktopLyric />} />
<Route path="*" element={<MainRoutes />} />
</Routes>
<GlobalToast />
</>
)
}

View File

@@ -10,7 +10,7 @@ export default function Layout(): JSX.Element {
const isTransfer = pathname.startsWith('/transfer')
return (
<div className="flex h-full w-full flex-col">
<div className="relative flex h-full w-full flex-col">
<LyricEngine />
{/* 上半:侧栏 + 内容 */}
<div className="flex min-h-0 flex-1">

View File

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

View File

@@ -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<Music[]>([])
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<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 已是根相对完整路径;无封面时回退首曲
const cover = album
? 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 (
<div>
<button
@@ -56,13 +106,24 @@ export default function AlbumDetail(): JSX.Element {
<p className="text-sm text-[var(--text-secondary)]">
{album?.group} · {songs.length}
</p>
<div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => songs.length && playList(songs, 0)}
className="apple-btn-primary"
>
<Play size={16} className="fill-current" />
</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>

View File

@@ -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<HTMLDivElement>(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<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 = (
<div className="flex items-center gap-2">
<button
@@ -352,6 +422,43 @@ export default function Lyric(): JSX.Element {
>
</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>
)

View File

@@ -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 {
<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>{' '}
LoveLive
LoveLive
{serverStatus.running ? '应用并重启' : '应用并启动'}
</p>
</>
) : (
@@ -519,7 +523,7 @@ export default function Settings(): JSX.Element {
)}
<p className="mt-4 text-sm text-[var(--text-secondary)]">
LoveLiveMusicPlayer · Apple
LoveLiveMusicPlayer
</p>
<p className="mt-1 text-xs text-[var(--text-secondary)]">
Electron + Vite + React · transVer 1

View File

@@ -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) {
navigator.mediaSession.setActionHandler('nexttrack', () => get().next())
navigator.mediaSession.setActionHandler('previoustrack', () => get().prev())

View File

@@ -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<void>
}
let toastTimer: ReturnType<typeof setTimeout> | 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<UIState>((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<UIState>((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<ThemeMode>('theme')) || 'system'
const accent = (await window.api.store.get<string>('accentColor')) || '#0A84FF'

View File

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

View File

@@ -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',
// ---- 窗口控制 ----

View File

@@ -18,7 +18,6 @@ export default {
]
},
colors: {
// Apple 系统色板
apple: {
// 主强调色跟随用户设置(--accent-rgb支持 /透明度 语法
blue: 'rgb(var(--accent-rgb) / <alpha-value>)',