adapte macos

This commit is contained in:
2026-08-08 22:45:23 +08:00
parent 0d7438e368
commit 1fa69aa055
46 changed files with 2301 additions and 933 deletions

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
# macOS 代码签名 + 公证(复制为 .env 后填入真实值;勿提交 .env
# 团队 ID 来自证书Apple Distribution / Developer ID 括号内
# --- 推荐用「Developer ID Application」签名后再公证App Store 外分发)---
# 钥匙串中证书名,或证书 SHA-1多证书同名时建议用哈希
# CSC_NAME=Developer ID Application: Your Name (TEAMID)
# CSC_NAME=0123456789ABCDEF0123456789ABCDEF01234567
CSC_NAME=
# --- 公证凭证(三选一)---
# 方案 AApple ID + App 专用密码appleid.apple.com → 登录与安全 → App 专用密码)
APPLE_ID=
APPLE_APP_SPECIFIC_PASSWORD=
APPLE_TEAM_ID=
# 方案 BApp Store Connect API Key.p8
# APPLE_API_KEY=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
# APPLE_API_KEY_ID=XXXXXXXXXX
# APPLE_API_ISSUER=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# 方案 C已配置的 notarytool keychain profile
# APPLE_KEYCHAIN=/path/to/login.keychain-db
# APPLE_KEYCHAIN_PROFILE=AC_PASSWORD

5
.gitignore vendored
View File

@@ -7,3 +7,8 @@ out
.idea
.vscode/*
!.vscode/extensions.json
.env
.env.local
*.p8
*.p12
AuthKey_*.p8

1
.npmrc
View File

@@ -1,2 +1,3 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
ffmpeg_binaries_url=https://cdn.npmmirror.com/binaries/ffmpeg-static

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened Runtime + 公证所需Electron -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- 本机 HTTP / WebSocket / 出网(曲库、更新、传歌) -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- 选择曲库目录、导出等 -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>

BIN
build/icon.icns Normal file

Binary file not shown.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

23
build/icon.svg Normal file
View File

@@ -0,0 +1,23 @@
<svg width="232" height="232" viewBox="0 0 232 232" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_b)">
<path d="M231.5 1V0.5H231H1H0.5V1V231V231.5H1H231H231.5V231V1ZM40.5 25C40.5 33.0082 34.0082 39.5 26 39.5C17.9918 39.5 11.5 33.0082 11.5 25C11.5 16.9918 17.9918 10.5 26 10.5C34.0082 10.5 40.5 16.9918 40.5 25ZM220.5 25C220.5 33.0082 214.008 39.5 206 39.5C197.992 39.5 191.5 33.0082 191.5 25C191.5 16.9918 197.992 10.5 206 10.5C214.008 10.5 220.5 16.9918 220.5 25ZM40.5 205C40.5 213.008 34.0082 219.5 26 219.5C17.9918 219.5 11.5 213.008 11.5 205C11.5 196.992 17.9918 190.5 26 190.5C34.0082 190.5 40.5 196.992 40.5 205ZM220.5 205C220.5 213.008 214.008 219.5 206 219.5C197.992 219.5 191.5 213.008 191.5 205C191.5 196.992 197.992 190.5 206 190.5C214.008 190.5 220.5 196.992 220.5 205ZM209.5 111C209.5 162.639 167.639 204.5 116 204.5C64.3613 204.5 22.5 162.639 22.5 111C22.5 59.3613 64.3613 17.5 116 17.5C167.639 17.5 209.5 59.3613 209.5 111Z" fill="white" stroke="white"/>
<path d="M63.5 146.5C63.5 149.959 60.8969 152.5 58 152.5C55.1031 152.5 52.5 149.959 52.5 146.5C52.5 143.041 55.1031 140.5 58 140.5C60.8969 140.5 63.5 143.041 63.5 146.5Z" stroke="white" stroke-width="5"/>
<path d="M54.9856 139.466C54.9856 139.466 51.1973 116.315 83.1874 93.1647C115.178 70.014 133.698 69.5931 133.698 69.5931" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M178.902 142.686C177.27 139.853 173.652 138.88 170.819 140.512C167.987 142.144 167.014 145.762 168.646 148.595C170.277 151.427 173.896 152.4 176.728 150.768C179.561 149.137 180.534 145.518 178.902 142.686Z" stroke="white" stroke-width="5"/>
<path d="M169.409 151.555C169.409 151.555 151.24 166.394 115.211 150.232C79.182 134.07 69.5718 118.232 69.5718 118.232" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M109.577 41.9707C107.966 44.8143 108.964 48.4262 111.808 50.038C114.651 51.6498 118.263 50.6512 119.875 47.8075C121.487 44.9639 120.488 41.3521 117.645 39.7403C114.801 38.1285 111.189 39.1271 109.577 41.9707Z" stroke="white" stroke-width="5"/>
<path d="M122.038 45.6467C122.038 45.6467 144.047 53.7668 148.412 93.0129C152.778 132.259 144.012 148.579 144.012 148.579" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M59.6334 105C59.6334 105 50.4373 82.1038 61.3054 73.3616C72.1736 64.6194 96 69.1987 96 69.1987" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M149.532 66.9784C149.532 66.9784 174.391 68.9134 177.477 82.6384C180.564 96.3634 165.799 115.833 165.799 115.833" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M138.248 163.363C138.248 163.363 124.023 183.841 110.618 179.573C97.2129 175.305 87.8662 152.728 87.8662 152.728" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M116 119C120.418 119 124 115.642 124 111.5C124 107.358 120.418 104 116 104C111.582 104 108 107.358 108 111.5C108 115.642 111.582 119 116 119Z" fill="white"/>
</g>
<defs>
<filter id="filter0_b" x="-4" y="-4" width="240" height="240" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImage" stdDeviation="2"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
build/icons/1024x1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
build/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
build/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
build/icons/24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
build/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
build/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
build/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
build/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

BIN
build/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
build/icons/96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -18,6 +18,7 @@ asarUnpack:
- resources/**
- '**/*.node'
win:
icon: build/icon.ico
target:
- nsis
nsis:
@@ -25,11 +26,25 @@ nsis:
shortcutName: ${productName}
oneClick: false
allowToChangeInstallationDirectory: true
installerIcon: build/icon.ico
uninstallerIcon: build/icon.ico
mac:
icon: build/icon.icns
target:
- dmg
category: public.app-category.music
# App Store 外分发:需 Developer ID Application 签名 + 公证
hardenedRuntime: true
gatekeeperAssess: false
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
# electron-builder 26布尔开关凭证见环境变量 / .env
# APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID
# 或 APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER
notarize: true
linux:
icon: build/icons
target:
- AppImage
category: Audio

1903
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,13 +11,13 @@
"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": "electron-vite preview",
"dev": "electron-vite dev",
"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",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux",
"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:mac": "npm run build && node scripts/run-electron-builder.mjs --mac",
"build:linux": "npm run build && node scripts/run-electron-builder.mjs --linux",
"monkey": "node scripts/monkey-test.mjs --complex",
"monkey:build": "npx electron-vite build && node scripts/monkey-test.mjs --complex",
"monkey:heavy": "npx electron-vite build && node scripts/monkey-test.mjs --complex --actions=300 --storm=60"

BIN
resources/icon.icns Normal file

Binary file not shown.

BIN
resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

23
resources/icon.svg Normal file
View File

@@ -0,0 +1,23 @@
<svg width="232" height="232" viewBox="0 0 232 232" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_b)">
<path d="M231.5 1V0.5H231H1H0.5V1V231V231.5H1H231H231.5V231V1ZM40.5 25C40.5 33.0082 34.0082 39.5 26 39.5C17.9918 39.5 11.5 33.0082 11.5 25C11.5 16.9918 17.9918 10.5 26 10.5C34.0082 10.5 40.5 16.9918 40.5 25ZM220.5 25C220.5 33.0082 214.008 39.5 206 39.5C197.992 39.5 191.5 33.0082 191.5 25C191.5 16.9918 197.992 10.5 206 10.5C214.008 10.5 220.5 16.9918 220.5 25ZM40.5 205C40.5 213.008 34.0082 219.5 26 219.5C17.9918 219.5 11.5 213.008 11.5 205C11.5 196.992 17.9918 190.5 26 190.5C34.0082 190.5 40.5 196.992 40.5 205ZM220.5 205C220.5 213.008 214.008 219.5 206 219.5C197.992 219.5 191.5 213.008 191.5 205C191.5 196.992 197.992 190.5 206 190.5C214.008 190.5 220.5 196.992 220.5 205ZM209.5 111C209.5 162.639 167.639 204.5 116 204.5C64.3613 204.5 22.5 162.639 22.5 111C22.5 59.3613 64.3613 17.5 116 17.5C167.639 17.5 209.5 59.3613 209.5 111Z" fill="white" stroke="white"/>
<path d="M63.5 146.5C63.5 149.959 60.8969 152.5 58 152.5C55.1031 152.5 52.5 149.959 52.5 146.5C52.5 143.041 55.1031 140.5 58 140.5C60.8969 140.5 63.5 143.041 63.5 146.5Z" stroke="white" stroke-width="5"/>
<path d="M54.9856 139.466C54.9856 139.466 51.1973 116.315 83.1874 93.1647C115.178 70.014 133.698 69.5931 133.698 69.5931" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M178.902 142.686C177.27 139.853 173.652 138.88 170.819 140.512C167.987 142.144 167.014 145.762 168.646 148.595C170.277 151.427 173.896 152.4 176.728 150.768C179.561 149.137 180.534 145.518 178.902 142.686Z" stroke="white" stroke-width="5"/>
<path d="M169.409 151.555C169.409 151.555 151.24 166.394 115.211 150.232C79.182 134.07 69.5718 118.232 69.5718 118.232" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M109.577 41.9707C107.966 44.8143 108.964 48.4262 111.808 50.038C114.651 51.6498 118.263 50.6512 119.875 47.8075C121.487 44.9639 120.488 41.3521 117.645 39.7403C114.801 38.1285 111.189 39.1271 109.577 41.9707Z" stroke="white" stroke-width="5"/>
<path d="M122.038 45.6467C122.038 45.6467 144.047 53.7668 148.412 93.0129C152.778 132.259 144.012 148.579 144.012 148.579" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M59.6334 105C59.6334 105 50.4373 82.1038 61.3054 73.3616C72.1736 64.6194 96 69.1987 96 69.1987" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M149.532 66.9784C149.532 66.9784 174.391 68.9134 177.477 82.6384C180.564 96.3634 165.799 115.833 165.799 115.833" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M138.248 163.363C138.248 163.363 124.023 183.841 110.618 179.573C97.2129 175.305 87.8662 152.728 87.8662 152.728" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M116 119C120.418 119 124 115.642 124 111.5C124 107.358 120.418 104 116 104C111.582 104 108 107.358 108 111.5C108 115.642 111.582 119 116 119Z" fill="white"/>
</g>
<defs>
<filter id="filter0_b" x="-4" y="-4" width="240" height="240" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImage" stdDeviation="2"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,36 @@
/**
* 开发态跑的是 node_modules 里的 Electron.app菜单栏名称来自 Info.plist。
* 把 CFBundleName / CFBundleDisplayName 改成产品名(仅 macOSnpm i 后需再跑一次)。
*/
import { createRequire } from 'module'
import { execFileSync } from 'child_process'
import { existsSync } from 'fs'
import { dirname, join } from 'path'
import { platform } from 'os'
const APP_NAME = 'LoveLiveMusicPlayer'
if (platform() !== 'darwin') {
process.exit(0)
}
const require = createRequire(import.meta.url)
let electronBinary
try {
electronBinary = require('electron')
} catch {
process.exit(0)
}
const plist = join(dirname(String(electronBinary)), '..', 'Info.plist')
if (!existsSync(plist)) {
console.warn('[patch-electron] Info.plist 未找到:', plist)
process.exit(0)
}
for (const key of ['CFBundleName', 'CFBundleDisplayName']) {
execFileSync('plutil', ['-replace', key, '-string', APP_NAME, plist])
}
// 促使 Launch Services 刷新缓存
execFileSync('touch', [join(dirname(plist), '..')])
console.log(`[patch-electron] 开发态应用名 → ${APP_NAME}`)

View File

@@ -0,0 +1,39 @@
/**
* 加载项目根目录 .env 后启动 electron-builder不覆盖已有环境变量
*/
import { existsSync, readFileSync } from 'fs'
import { spawnSync } from 'child_process'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const envPath = join(root, '.env')
if (existsSync(envPath)) {
for (const raw of readFileSync(envPath, 'utf8').split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
const eq = line.indexOf('=')
if (eq <= 0) continue
const key = line.slice(0, eq).trim()
let val = line.slice(eq + 1).trim()
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1)
}
if (process.env[key] === undefined) process.env[key] = val
}
console.log('[env] loaded', envPath)
} else {
console.warn('[env] 未找到 .env公证需要 APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID')
}
const cli = join(root, 'node_modules/electron-builder/cli.js')
const r = spawnSync(process.execPath, [cli, ...process.argv.slice(2)], {
stdio: 'inherit',
env: process.env,
cwd: root
})
process.exit(r.status ?? 1)

View File

@@ -1,6 +1,7 @@
import { app, BrowserWindow } from 'electron'
import { app, BrowserWindow, nativeImage } from 'electron'
import { existsSync } from 'fs'
import { join } from 'path'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { createMainWindow, getMainWindow } from './window/mainWindow'
import { createTray, destroyTray } from './window/tray'
import { setQuitting } from './appState'
@@ -9,6 +10,26 @@ 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'
/** 开发态仍运行 Electron.app需手动改 Dock 图标;菜单栏名称仍会显示 ElectronInfo.plist 限制) */
function applyDevBranding(): void {
app.setName('LoveLiveMusicPlayer')
if (process.platform !== 'darwin' || !app.dock) return
const candidates = [
join(process.cwd(), 'resources', 'icon.png'),
join(app.getAppPath(), 'resources', 'icon.png'),
join(__dirname, '../../resources/icon.png')
]
const iconPath = candidates.find((p) => existsSync(p))
if (!iconPath) return
const img = nativeImage.createFromPath(iconPath)
if (!img.isEmpty()) app.dock.setIcon(img)
}
/** Monkey 测试:独立 userData并跳过单例锁避免与正在运行的实例冲突 */
const isMonkey = process.env.LLMP_MONKEY === '1'
@@ -34,14 +55,35 @@ if (!gotLock) {
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.zhushenwudi.lovelivemusicplayer')
if (is.dev) applyDevBranding()
app.on('browser-window-created', (_, window) => {
// 开发态 F12 等;默认 zoom:false 会拦一部分缩放快捷键,但不完整
optimizer.watchWindowShortcuts(window)
// Chromium 默认 ⌘/Ctrl + +/-/= /0 会缩放整页(看起来像字体变了)
// toolkit 只拦了 Minus 与 Shift+equal漏了 ⌘+=、小键盘等
window.webContents.setVisualZoomLevelLimits(1, 1)
window.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return
if (!(input.control || input.meta)) return
const zoomKey =
input.code === 'Minus' ||
input.code === 'Equal' ||
input.code === 'Digit0' ||
input.code === 'NumpadAdd' ||
input.code === 'NumpadSubtract' ||
input.code === 'Numpad0'
if (zoomKey) event.preventDefault()
})
// ⌘/Win + ←/→ 快退/快进 5s仅聚焦时
bindSeekShortcuts(window)
})
initDatabase()
registerIpc()
createMainWindow()
createTray()
// ⌘/Win + P 播放/暂停(全局,托盘后台也可用)
registerMediaShortcuts()
app.on('activate', () => {
const win = getMainWindow()
@@ -60,6 +102,7 @@ if (!gotLock) {
app.on('before-quit', () => {
setQuitting(true)
unregisterMediaShortcuts()
destroyTray()
stopFileServer()
stopMusicServer()

View File

@@ -20,7 +20,8 @@ import { startDataServer, stopDataServer, sendDataCmd } from '../services/dataSe
import {
toggleDesktopLyric,
updateDesktopLyric,
setLyricIgnoreMouse
setLyricIgnoreMouse,
isPointerInsideLyricWindow
} from '../window/desktopLyricWindow'
import { checkUpdate } from '../services/update'
import { exportSongs, type ExportItem } from '../services/export'
@@ -66,10 +67,13 @@ export function registerIpc(): void {
/* ---------- 曲库同步 ---------- */
ipcMain.handle(IPC.LIBRARY_SYNC, async () => {
const result = await syncLibrary()
const syncedAt = new Date().toISOString()
store.set('dataVersion', String(result.version))
return result
store.set('librarySyncedAt', syncedAt)
return { ...result, syncedAt }
})
ipcMain.handle(IPC.LIBRARY_DATA_VERSION, () => store.get('dataVersion'))
ipcMain.handle(IPC.LIBRARY_DATA_VERSION, () => store.get('dataVersion') || '')
ipcMain.handle(IPC.LIBRARY_SYNCED_AT, () => store.get('librarySyncedAt') || '')
/* ---------- 转码 ---------- */
ipcMain.handle(IPC.CONVERT_START, async (_e, input: string, output: string) => {
@@ -138,6 +142,7 @@ export function registerIpc(): void {
})
ipcMain.on(IPC.LYRIC_UPDATE, (_e, payload) => updateDesktopLyric(payload))
ipcMain.on(IPC.LYRIC_SET_IGNORE_MOUSE, (_e, ignore: boolean) => setLyricIgnoreMouse(ignore))
ipcMain.handle(IPC.LYRIC_POINTER_INSIDE, () => isPointerInsideLyricWindow())
/* ---------- 窗口控制 ---------- */
ipcMain.on(IPC.WIN_MIN, (e) => BrowserWindow.fromWebContents(e.sender)?.minimize())

View File

@@ -7,11 +7,42 @@ import { HTTP_PORT_RANGE } from '@shared/protocol'
/**
* 本地 HTTP 静态文件服务(托管曲库目录),供播放器与手机下载歌曲/封面/歌词。
* 相比旧版跑在渲染进程,这里迁到主进程更安全。
*
* 注意start/stop 必须串行。开发态 React StrictMode 会双跑 boot
* 并发 start 会覆盖 module 级 server 引用导致旧端口监听泄漏10000、10001…叠罗汉
* 引用挂在 globalThis 上,避免主进程热更新丢引用。
*/
let server: http.Server | null = null
let currentPort = 0
let currentRoot = ''
type FileServerGlobal = {
__llmpFileServer?: http.Server | null
__llmpFileServerPort?: number
__llmpFileServerRoot?: string
__llmpFileServerChain?: Promise<unknown>
}
const g = globalThis as unknown as FileServerGlobal
function getServer(): http.Server | null {
return g.__llmpFileServer ?? null
}
function setServerState(server: http.Server | null, port: number, root: string): void {
g.__llmpFileServer = server
g.__llmpFileServerPort = port
g.__llmpFileServerRoot = root
}
/** 将 start/stop 排入同一队列,避免并发互相踩引用 */
function enqueue<T>(fn: () => Promise<T>): Promise<T> {
const next = (g.__llmpFileServerChain ?? Promise.resolve())
.catch(() => undefined)
.then(fn)
g.__llmpFileServerChain = next.then(
() => undefined,
() => undefined
)
return next
}
/** 探测可用端口(占用则 +1直到上限 */
function findAvailablePort(start: number): Promise<number> {
@@ -38,52 +69,70 @@ export interface FileServerStatus {
root: string
}
export async function startFileServer(root: string, preferredPort: number): Promise<FileServerStatus> {
await stopFileServer()
// 曲库 base_url 以 "LoveLive/" 开头,根目录须为其父级。
// 兼容用户直接选中 LoveLive 目录的情况,自动上退一级。
if (basename(root).toLowerCase() === 'lovelive') {
root = dirname(root)
}
const port = await findAvailablePort(preferredPort)
const application = express()
application.use((_, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*')
next()
})
// 允许浏览器缓存封面/音频:曲库文件按路径寻址、极少变动。
// 若禁缓存(no-store),切换页面会整批重拉,配合 Chromium 每主机 6 连接上限
// 与被中断的请求,容易把连接池打满导致图片全部加载失败。
application.use(express.static(root, { maxAge: '7d', etag: true, lastModified: true }))
async function stopFileServerUnlocked(): Promise<void> {
const server = getServer()
setServerState(null, 0, '')
if (!server) return
return new Promise((resolve, reject) => {
server = http.createServer(application)
// 关闭 keep-alive避免被中断(切页/切歌)的请求占住 socket 导致后续加载卡死
server.keepAliveTimeout = 0
server.on('error', reject)
server.listen(port, '0.0.0.0', () => {
currentPort = port
currentRoot = root
resolve({ running: true, port, root })
})
// 先掐断连接,否则 close 回调可能一直不触发,后续 start 误以为已停干净
if (typeof server.closeAllConnections === 'function') {
server.closeAllConnections()
}
await new Promise<void>((resolve) => {
let done = false
const finish = (): void => {
if (done) return
done = true
resolve()
}
server.close(() => finish())
setTimeout(finish, 500)
})
}
export function stopFileServer(): Promise<void> {
return new Promise((resolve) => {
if (server) {
server.close(() => {
server = null
currentPort = 0
currentRoot = ''
resolve()
})
} else {
resolve()
return enqueue(() => stopFileServerUnlocked())
}
export function startFileServer(root: string, preferredPort: number): Promise<FileServerStatus> {
return enqueue(async () => {
await stopFileServerUnlocked()
// 曲库 base_url 以 "LoveLive/" 开头,根目录须为其父级。
// 兼容用户直接选中 LoveLive 目录的情况,自动上退一级。
if (basename(root).toLowerCase() === 'lovelive') {
root = dirname(root)
}
const port = await findAvailablePort(preferredPort)
const application = express()
application.use((_, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*')
next()
})
// 允许浏览器缓存封面/音频:曲库文件按路径寻址、极少变动。
// 若禁缓存(no-store),切换页面会整批重拉,配合 Chromium 每主机 6 连接上限
// 与被中断的请求,容易把连接池打满导致图片全部加载失败。
application.use(express.static(root, { maxAge: '7d', etag: true, lastModified: true }))
const server = http.createServer(application)
// 关闭 keep-alive避免被中断(切页/切歌)的请求占住 socket 导致后续加载卡死
server.keepAliveTimeout = 0
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '0.0.0.0', () => resolve())
})
setServerState(server, port, root)
return { running: true, port, root }
})
}
export function getFileServerStatus(): FileServerStatus {
return { running: !!server, port: currentPort, root: currentRoot }
const server = getServer()
return {
running: !!server,
port: g.__llmpFileServerPort ?? 0,
root: g.__llmpFileServerRoot ?? ''
}
}

View File

@@ -55,6 +55,8 @@ export interface SyncResult {
version: number
albums: number
musics: number
/** 拉取完成时间(由 IPC 层写入并返回) */
syncedAt?: string
}
export async function syncLibrary(): Promise<SyncResult> {

View File

@@ -0,0 +1,53 @@
import { globalShortcut, type BrowserWindow } from 'electron'
import { sendPlayerControl } from './playerControl'
/**
* 媒体快捷键:
* - 播放/暂停:全局 Super+PmacOS ⌘P / Windows Win+P托盘后台也可用
* - 快退/快进:仅窗口聚焦时 Super+← / Super+→,避免全局抢占方向键
*
* Electron 的 Super = Windows 徽标键 / macOS Command。
* 注意Windows 系统常占用 Win+P投影注册失败时会打日志。
*/
const GLOBAL_PLAY_PAUSE = 'Super+P'
export function registerMediaShortcuts(): void {
try {
const ok = globalShortcut.register(GLOBAL_PLAY_PAUSE, () =>
sendPlayerControl('playpause')
)
if (!ok) {
console.warn(`[shortcuts] 注册失败(可能被系统占用): ${GLOBAL_PLAY_PAUSE}`)
}
} catch (e) {
console.warn(`[shortcuts] 注册异常: ${GLOBAL_PLAY_PAUSE}`, e)
}
}
export function unregisterMediaShortcuts(): void {
try {
globalShortcut.unregister(GLOBAL_PLAY_PAUSE)
} catch {
/* 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

@@ -0,0 +1,16 @@
import { IPC } from '@shared/ipc/channels'
import { getMainWindow } from '../window/mainWindow'
export type PlayerControlAction =
| 'playpause'
| 'next'
| 'prev'
| 'seek-forward'
| 'seek-backward'
/** 向主窗口渲染进程下发播放控制(窗口隐藏到托盘时仍可收到) */
export function sendPlayerControl(action: PlayerControlAction): void {
const win = getMainWindow()
if (!win || win.isDestroyed()) return
win.webContents.send(IPC.PLAYER_CONTROL, action)
}

View File

@@ -6,8 +6,10 @@ export interface AppConfig {
serverPath: string
/** HTTP 文件服务端口 */
serverPort: number
/** 歌曲源 URL本地或远程播放器与传输据此取流 */
/** 远程曲库地址(切换到本地时保留,不自动清空) */
url: string
/** 封面/音频来源local=本地文件服务remote=url */
mediaSource: 'local' | 'remote'
/** 播放模式order/repeat/single/shuffle */
playMode: 'order' | 'repeat' | 'single' | 'shuffle'
/** 音量 0-1 */
@@ -18,6 +20,8 @@ export interface AppConfig {
accentColor: string
/** 数据版本OSS data.json 版本) */
dataVersion: string
/** 上次从云端拉取曲库的时间ISO 字符串) */
librarySyncedAt: string
/** 上次播放列表musicUId 数组) */
lastPlayList: string[]
/** 上次播放曲目 */
@@ -36,11 +40,13 @@ const defaults: AppConfig = {
serverPath: '',
serverPort: 10000,
url: '',
mediaSource: 'local',
playMode: 'order',
volume: 0.8,
theme: 'system',
accentColor: '#0A84FF',
dataVersion: '',
librarySyncedAt: '',
lastPlayList: [],
lastPlayId: '',
lastPlayIndex: 0,

View File

@@ -6,6 +6,9 @@ import { IPC } from '@shared/ipc/channels'
let lyricWindow: BrowserWindow | null = null
/** 缓存最近一次歌词推送,窗口新建/显示时立刻回放,避免先显示占位文案 */
let lastPayload: unknown = null
/** 轮询光标是否在窗口内,驱动黑底显隐(与 -webkit-app-region:drag 解耦) */
let hoverPoll: ReturnType<typeof setInterval> | null = null
let lastHoverInside: boolean | null = null
function pushLastPayload(): void {
if (lastPayload != null && lyricWindow && !lyricWindow.isDestroyed()) {
@@ -13,10 +16,30 @@ function pushLastPayload(): void {
}
}
function stopHoverPoll(): void {
if (hoverPoll) {
clearInterval(hoverPoll)
hoverPoll = null
}
lastHoverInside = null
}
function startHoverPoll(): void {
stopHoverPoll()
hoverPoll = setInterval(() => {
if (!lyricWindow || lyricWindow.isDestroyed() || !lyricWindow.isVisible()) return
const inside = isPointerInsideLyricWindow()
if (inside === lastHoverInside) return
lastHoverInside = inside
lyricWindow.webContents.send(IPC.LYRIC_HOVER, inside)
}, 50)
}
export function toggleDesktopLyric(show: boolean): void {
if (show) {
if (lyricWindow) {
lyricWindow.showInactive()
startHoverPoll()
// 已存在窗口再次打开时,立即补发当前歌词
pushLastPayload()
return
@@ -75,12 +98,20 @@ export function toggleDesktopLyric(show: boolean): void {
lyricWindow.webContents.on('did-finish-load', () => pushLastPayload())
lyricWindow.once('ready-to-show', () => {
lyricWindow?.showInactive()
startHoverPoll()
pushLastPayload()
})
lyricWindow.on('closed', () => {
stopHoverPoll()
lyricWindow = null
})
lyricWindow.on('hide', () => {
stopHoverPoll()
lastHoverInside = null
})
lyricWindow.on('show', () => startHoverPoll())
} else {
stopHoverPoll()
lyricWindow?.hide()
}
}
@@ -94,3 +125,11 @@ export function updateDesktopLyric(payload: unknown): void {
export function setLyricIgnoreMouse(ignore: boolean): void {
lyricWindow?.setIgnoreMouseEvents(ignore, { forward: true })
}
/** 光标是否仍在桌面歌词窗口矩形内 */
export function isPointerInsideLyricWindow(): boolean {
if (!lyricWindow || lyricWindow.isDestroyed()) return false
const p = screen.getCursorScreenPoint()
const b = lyricWindow.getBounds()
return p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height
}

View File

@@ -1,11 +1,24 @@
import { BrowserWindow, shell } from 'electron'
import { app, BrowserWindow, shell } from 'electron'
import { existsSync } from 'fs'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import { isQuitting } from '../appState'
let mainWindow: BrowserWindow | null = null
/** 窗口 / 任务栏图标(与旧版 assets/icon.png 同源) */
function resolveAppIcon(): string | undefined {
const candidates = [
join(process.resourcesPath, 'icon.png'),
join(app.getAppPath(), 'resources', 'icon.png'),
join(process.cwd(), 'resources', 'icon.png'),
join(__dirname, '../../resources/icon.png')
]
return candidates.find((p) => existsSync(p))
}
export function createMainWindow(): BrowserWindow {
const icon = resolveAppIcon()
// 默认 / 最小尺寸对齐当前常用显示尺寸
mainWindow = new BrowserWindow({
width: 1377,
@@ -15,6 +28,7 @@ export function createMainWindow(): BrowserWindow {
show: false,
frame: false,
titleBarStyle: 'hiddenInset',
...(icon ? { icon } : {}),
// macOS 毛玻璃质感
vibrancy: 'under-window',
visualEffectState: 'active',

View File

@@ -1,9 +1,9 @@
import { app, Menu, Tray, nativeImage } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { IPC } from '@shared/ipc/channels'
import { setQuitting } from '../appState'
import { getMainWindow } from './mainWindow'
import { sendPlayerControl } from '../services/playerControl'
let tray: Tray | null = null
@@ -21,13 +21,6 @@ function resolveTrayIcon(): string {
return candidates.find((p) => existsSync(p)) ?? candidates[0]
}
function control(action: 'playpause' | 'next' | 'prev'): void {
const win = getMainWindow()
if (!win || win.isDestroyed()) return
// 隐藏窗口时也要能收到控制指令
win.webContents.send(IPC.PLAYER_CONTROL, action)
}
function showMain(): void {
const win = getMainWindow()
if (!win) return
@@ -80,9 +73,9 @@ export function createTray(): void {
const items: Electron.MenuItemConstructorOptions[] = [
{ label: '显示主界面', click: showMain },
{ type: 'separator' },
{ label: '播放 / 暂停', click: () => control('playpause') },
{ label: '上一首', click: () => control('prev') },
{ label: '下一首', click: () => control('next') },
{ label: '播放 / 暂停', click: () => sendPlayerControl('playpause') },
{ label: '上一首', click: () => sendPlayerControl('prev') },
{ label: '下一首', click: () => sendPlayerControl('next') },
{ type: 'separator' },
{ label: '退出', click: quitApp }
]
@@ -97,9 +90,9 @@ export function createTray(): void {
Menu.buildFromTemplate([
{ label: '显示主界面', click: showMain },
{ type: 'separator' },
{ label: '播放 / 暂停', click: () => control('playpause') },
{ label: '上一首', click: () => control('prev') },
{ label: '下一首', click: () => control('next') },
{ label: '播放 / 暂停', click: () => sendPlayerControl('playpause') },
{ label: '上一首', click: () => sendPlayerControl('prev') },
{ label: '下一首', click: () => sendPlayerControl('next') },
{ type: 'separator' },
{ label: '退出', click: quitApp }
])

View File

@@ -30,9 +30,14 @@ const api = {
fetchText: (url: string): Promise<string> => ipcRenderer.invoke(IPC.NET_FETCH_TEXT, url)
},
library: {
sync: (): Promise<{ version: number; albums: number; musics: number }> =>
ipcRenderer.invoke(IPC.LIBRARY_SYNC),
dataVersion: (): Promise<string> => ipcRenderer.invoke(IPC.LIBRARY_DATA_VERSION)
sync: (): Promise<{
version: number
albums: number
musics: number
syncedAt: string
}> => ipcRenderer.invoke(IPC.LIBRARY_SYNC),
dataVersion: (): Promise<string> => ipcRenderer.invoke(IPC.LIBRARY_DATA_VERSION),
syncedAt: (): Promise<string> => ipcRenderer.invoke(IPC.LIBRARY_SYNCED_AT)
},
convert: {
start: (input: string, output: string) =>
@@ -85,6 +90,12 @@ const api = {
toggle: (show: boolean) => ipcRenderer.invoke(IPC.LYRIC_TOGGLE, show),
update: (payload: unknown) => ipcRenderer.send(IPC.LYRIC_UPDATE, payload),
setIgnoreMouse: (ignore: boolean) => ipcRenderer.send(IPC.LYRIC_SET_IGNORE_MOUSE, ignore),
pointerInside: (): Promise<boolean> => ipcRenderer.invoke(IPC.LYRIC_POINTER_INSIDE),
onHover: (cb: (inside: boolean) => void) => {
const listener = (_: IpcRendererEvent, inside: boolean): void => cb(inside)
ipcRenderer.on(IPC.LYRIC_HOVER, listener)
return () => ipcRenderer.removeListener(IPC.LYRIC_HOVER, listener)
},
onUpdate: (cb: (payload: unknown) => void) => {
const listener = (_: IpcRendererEvent, p: unknown): void => cb(p)
ipcRenderer.on(IPC.LYRIC_UPDATE, listener)
@@ -102,9 +113,13 @@ const api = {
close: () => ipcRenderer.send(IPC.WIN_CLOSE)
},
player: {
onControl: (cb: (action: 'playpause' | 'next' | 'prev') => void) => {
const listener = (_: IpcRendererEvent, action: 'playpause' | 'next' | 'prev'): void =>
cb(action)
onControl: (
cb: (action: 'playpause' | 'next' | 'prev' | 'seek-forward' | 'seek-backward') => void
) => {
const listener = (
_: IpcRendererEvent,
action: 'playpause' | 'next' | 'prev' | 'seek-forward' | 'seek-backward'
): void => cb(action)
ipcRenderer.on(IPC.PLAYER_CONTROL, listener)
return () => ipcRenderer.removeListener(IPC.PLAYER_CONTROL, listener)
}

View File

@@ -67,6 +67,9 @@ function MainRoutes(): JSX.Element {
)
}
/** StrictMode 会双挂载 effect避免重复 boot 并发 http.start */
let appBootStarted = false
export default function App(): JSX.Element {
const initUI = useUIStore((s) => s.init)
const initPlayer = usePlayerStore((s) => s.init)
@@ -77,12 +80,19 @@ export default function App(): JSX.Element {
useEffect(() => {
if (isDesktopLyric) return
if (appBootStarted) return
appBootStarted = true
const boot = async (): Promise<void> => {
try {
await initUI()
const root = await window.api.store.get<string>('serverPath')
const port = (await window.api.store.get<number>('serverPort')) || 10000
if (root) {
const remoteUrl = (await window.api.store.get<string>('url')) || ''
const mediaSource =
(await window.api.store.get<'local' | 'remote'>('mediaSource')) ||
(remoteUrl.trim() ? 'remote' : 'local')
// 仅本地来源时自动起文件服务;远程模式下起服务会与「保存并启用」停服意图冲突
if (root && mediaSource === 'local') {
try {
await window.api.http.start(root, port)
} catch (e) {

View File

@@ -12,7 +12,6 @@ import {
Shuffle,
ListOrdered,
Mic2,
MonitorSpeaker,
ListPlus,
ListMusic,
Plus,
@@ -312,7 +311,7 @@ export default function PlayerBar(): JSX.Element {
: 'text-[var(--text-secondary)] hover:text-[var(--text)]'
)}
>
<MonitorSpeaker size={18} strokeWidth={2} />
<span className="text-[15px] font-bold leading-none tracking-tight"></span>
</button>
</div>
<div className="pointer-events-auto flex w-[min(520px,46vw)] items-center gap-2 text-[11px] text-[var(--text-secondary)]">

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
import { X, Minus, Plus, Lock, Unlock, Palette, Languages } from 'lucide-react'
import type { DesktopLrcLang, DesktopLrcPayload } from '../components/LyricEngine'
@@ -26,6 +26,7 @@ export default function DesktopLyric(): JSX.Element {
const [payload, setPayload] = useState<DesktopLrcPayload | null>(null)
const [fontSize, setFontSize] = useState(30)
const [color, setColor] = useState(DEFAULT_COLOR)
/** 由主进程根据光标与窗口矩形推送,不依赖渲染进程 mouseenter与 drag 冲突) */
const [hover, setHover] = useState(false)
const [locked, setLocked] = useState(false)
const [showColors, setShowColors] = useState(false)
@@ -33,7 +34,6 @@ export default function DesktopLyric(): JSX.Element {
const [singleLine, setSingleLine] = useState(
() => (typeof window !== 'undefined' ? window.innerHeight <= SINGLE_LINE_MAX_H : true)
)
const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
window.api.store.get<number>('lrcFontSize').then((v) => v && setFontSize(v))
@@ -42,7 +42,7 @@ export default function DesktopLyric(): JSX.Element {
if (v === 'jp' || v === 'zh' || v === 'roma') setLang(v)
})
const off = window.api.lyric.onUpdate((p: unknown) => {
const offUpdate = window.api.lyric.onUpdate((p: unknown) => {
const next = p as DesktopLrcPayload & { text?: string; sub?: string }
// 兼容旧缓存:仅 text/sub 时转成新结构
if (next?.jp && next?.zh && next?.roma) {
@@ -59,6 +59,11 @@ export default function DesktopLyric(): JSX.Element {
}
})
const offHover = window.api.lyric.onHover((inside) => {
setHover(inside)
if (!inside) setShowColors(false)
})
const onResize = (): void => {
setSingleLine(window.innerHeight <= SINGLE_LINE_MAX_H)
}
@@ -66,12 +71,18 @@ export default function DesktopLyric(): JSX.Element {
window.addEventListener('resize', onResize)
return () => {
off()
offUpdate()
offHover()
window.removeEventListener('resize', onResize)
if (leaveTimer.current) clearTimeout(leaveTimer.current)
}
}, [])
// 锁定态:指针进入范围时临时关闭穿透以便点工具条,移出后恢复
useEffect(() => {
if (!locked) return
window.api.lyric.setIgnoreMouse(!hover)
}, [locked, hover])
const pack = useMemo(() => {
if (!payload) return emptyPack
return payload[lang] ?? emptyPack
@@ -95,7 +106,8 @@ export default function DesktopLyric(): JSX.Element {
const next = !locked
setLocked(next)
setShowColors(false)
window.api.lyric.setIgnoreMouse(next)
// 锁定后先按当前 hover 决定是否穿透;未锁定则关闭穿透
window.api.lyric.setIgnoreMouse(next ? !hover : false)
}
const cycleLang = (): void => {
@@ -107,38 +119,17 @@ export default function DesktopLyric(): JSX.Element {
})
}
const clearLeaveTimer = (): void => {
if (leaveTimer.current) {
clearTimeout(leaveTimer.current)
leaveTimer.current = null
}
}
const onEnter = (): void => {
clearLeaveTimer()
setHover(true)
if (locked) window.api.lyric.setIgnoreMouse(false)
}
const onLeave = (): void => {
clearLeaveTimer()
leaveTimer.current = setTimeout(() => {
setHover(false)
setShowColors(false)
if (locked) window.api.lyric.setIgnoreMouse(true)
leaveTimer.current = null
}, 160)
}
const toolVisible = hover || showColors
const showBg = toolVisible && !locked
const showBg = hover || showColors
// 日文双行上行左、下行右KTV中/罗马双行居中
const ktvJp = !singleLine && lang === 'jp'
return (
/*
黑底显隐:主进程轮询光标 vs 窗口矩形IPC lyric:hover
拖拽:未锁定时整窗 drag与 hover 检测完全解耦,互不干扰。
*/
<div
onMouseEnter={onEnter}
onMouseLeave={onLeave}
style={{
...(locked ? noDrag : drag),
backgroundColor: 'rgba(0,0,0,0.01)'
@@ -205,15 +196,13 @@ export default function DesktopLyric(): JSX.Element {
)}
</div>
{/* 歌词区 */}
<div className="relative z-0 flex w-full max-w-full flex-col justify-center gap-1 px-4">
{singleLine ? (
<p
className="truncate text-center font-bold"
style={{
color,
fontSize,
textShadow: showBg ? 'none' : '0 2px 12px rgba(0,0,0,0.75)'
fontSize
}}
>
{pack.singleLrc || ' '}
@@ -227,8 +216,7 @@ export default function DesktopLyric(): JSX.Element {
fontSize,
textAlign: ktvJp ? 'left' : 'center',
maxWidth: ktvJp ? '66%' : '100%',
alignSelf: ktvJp ? 'flex-start' : 'center',
textShadow: showBg ? 'none' : '0 2px 12px rgba(0,0,0,0.75)'
alignSelf: ktvJp ? 'flex-start' : 'center'
}}
>
{pack.prevLrc || ' '}
@@ -240,8 +228,7 @@ export default function DesktopLyric(): JSX.Element {
fontSize,
textAlign: ktvJp ? 'right' : 'center',
maxWidth: ktvJp ? '66%' : '100%',
alignSelf: ktvJp ? 'flex-end' : 'center',
textShadow: showBg ? 'none' : '0 2px 12px rgba(0,0,0,0.75)'
alignSelf: ktvJp ? 'flex-end' : 'center'
}}
>
{pack.nextLrc || ' '}

View File

@@ -27,12 +27,12 @@ function lineVisual(dist: number, active: boolean): {
return { opacity: 1, scale: 1, blur: 0, y: 0, jpSize: 32, subSize: 17 }
}
if (dist === 1) {
return { opacity: 0.45, scale: 0.94, blur: 0.4, y: 2, jpSize: 22, subSize: 14 }
return { opacity: 0.45, scale: 0.96, blur: 0.4, y: 2, jpSize: 26, subSize: 15 }
}
if (dist === 2) {
return { opacity: 0.28, scale: 0.9, blur: 0.9, y: 4, jpSize: 20, subSize: 13 }
return { opacity: 0.28, scale: 0.93, blur: 0.9, y: 4, jpSize: 24, subSize: 14 }
}
return { opacity: 0.14, scale: 0.88, blur: 1.4, y: 6, jpSize: 18, subSize: 12 }
return { opacity: 0.14, scale: 0.9, blur: 1.4, y: 6, jpSize: 22, subSize: 13 }
}
/** 跟唱滚动ease-out接近 Apple 切行时的列表滑动感 */
@@ -58,6 +58,7 @@ function animateScrollTo(el: HTMLElement, to: number, duration = 580): () => voi
* - 右侧可手动滚动;播放时自动跟唱(用户滚动后短暂暂停跟唱)
*/
export default function Lyric(): JSX.Element {
const isMac = navigator.platform.toLowerCase().includes('mac')
const navigate = useNavigate()
const location = useLocation()
const { current, progress, httpBase, isPlaying, loves, toggleLove, seek, play } =
@@ -69,12 +70,19 @@ export default function Lyric(): JSX.Element {
const [showZh, setShowZh] = useState(true)
const [showRoma, setShowRoma] = useState(true)
const [leaving, setLeaving] = useState(false)
/** 入场动画结束后去掉 transform否则 Electron 不认 -webkit-app-region: drag */
const [sheetSettled, setSheetSettled] = useState(false)
const closingRef = useRef(false)
const prevCloseTick = useRef(closeTick)
const viewportRef = useRef<HTMLDivElement>(null)
const userScrollLock = useRef(false)
const resumeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const cancelScroll = useRef<(() => void) | null>(null)
const visualFocusRaf = useRef(0)
/** 程序化跟唱滚动中,避免被当成用户浏览 */
const programmaticScroll = useRef(false)
/** 视觉焦点行:跟唱时=播放行;手动滚动时=最靠近视口中线的行 */
const [visualFocusIdx, setVisualFocusIdx] = useState(0)
const isLoved = !!(current && loves.has(current.musicUId))
/** ≤1920 左栏居中;>1920 靠右,加宽只扩左侧空白 */
const [wideLayout, setWideLayout] = useState(
@@ -90,7 +98,14 @@ export default function Lyric(): JSX.Element {
// 每次进入歌词页重置,避免上次 leaving 残留导致「打不开」
useEffect(() => {
setLeaving(false)
setSheetSettled(false)
closingRef.current = false
userScrollLock.current = false
programmaticScroll.current = false
if (resumeTimer.current) {
clearTimeout(resumeTimer.current)
resumeTimer.current = null
}
prevCloseTick.current = closeTick
}, [location.key])
@@ -109,76 +124,170 @@ export default function Lyric(): JSX.Element {
return idx
}, [lines, progress])
const focusIdx = activeIdx < 0 ? 0 : activeIdx
/** 跟唱滚动目标(当前播放行);用 ref 避免回调重建间接触发回跳 */
const playFocusIdx = activeIdx < 0 ? 0 : activeIdx
const playFocusIdxRef = useRef(playFocusIdx)
playFocusIdxRef.current = playFocusIdx
const scrollToActive = useCallback(
(smooth: boolean, force = false): void => {
if (!force && userScrollLock.current) return
const viewport = viewportRef.current
if (!viewport || lines.length === 0) return
const activeEl = viewport.querySelector<HTMLElement>(`[data-line="${focusIdx}"]`)
if (!activeEl) return
// 焦点略低于视口中线,避免当前行视觉偏上
const top = Math.max(
0,
activeEl.offsetTop - viewport.clientHeight * 0.5 + activeEl.offsetHeight / 2
)
cancelScroll.current?.()
if (smooth) {
cancelScroll.current = animateScrollTo(viewport, top)
} else {
viewport.scrollTop = top
/** 按视口中线找最近歌词行,供手动滚动时驱动清晰度(模糊/透明度) */
const updateVisualFocusFromViewport = useCallback((): void => {
const viewport = viewportRef.current
if (!viewport || lines.length === 0) return
const viewportRect = viewport.getBoundingClientRect()
const focusY = viewportRect.top + viewportRect.height * 0.5
let best = 0
let bestDist = Number.POSITIVE_INFINITY
const nodes = viewport.querySelectorAll<HTMLElement>('[data-line]')
for (let n = 0; n < nodes.length; n++) {
const el = nodes[n]
const i = Number(el.dataset.line)
if (!Number.isFinite(i)) continue
const rect = el.getBoundingClientRect()
const center = rect.top + rect.height / 2
const d = Math.abs(center - focusY)
if (d < bestDist) {
bestDist = d
best = i
}
},
[focusIdx, lines.length]
)
}
setVisualFocusIdx((prev) => (prev === best ? prev : best))
}, [lines.length])
/** 显隐中文/罗马音后立刻按新高度回中(清掉用户滚动锁定) */
const recenterAfterLayout = useCallback((): void => {
const scrollToActive = useCallback((smooth: boolean): void => {
if (userScrollLock.current) return
const viewport = viewportRef.current
const idx = playFocusIdxRef.current
if (!viewport || lines.length === 0) return
const activeEl = viewport.querySelector<HTMLElement>(`[data-line="${idx}"]`)
if (!activeEl) return
// 焦点略低于视口中线,避免当前行视觉偏上
const top = Math.max(
0,
activeEl.offsetTop - viewport.clientHeight * 0.5 + activeEl.offsetHeight / 2
)
cancelScroll.current?.()
programmaticScroll.current = true
if (smooth) {
const stop = animateScrollTo(viewport, top)
cancelScroll.current = () => {
stop()
programmaticScroll.current = false
}
window.setTimeout(() => {
programmaticScroll.current = false
}, 650)
} else {
viewport.scrollTop = top
requestAnimationFrame(() => {
requestAnimationFrame(() => {
programmaticScroll.current = false
})
})
}
setVisualFocusIdx(idx)
}, [lines.length])
// 未手动浏览时,视觉焦点跟随播放行
useEffect(() => {
if (userScrollLock.current) return
setVisualFocusIdx(playFocusIdx)
}, [playFocusIdx])
/** 解锁并滚回播放行(仅语言开关等主动操作) */
const unlockAndRecenterPlaying = useCallback((): void => {
userScrollLock.current = false
if (resumeTimer.current) {
clearTimeout(resumeTimer.current)
resumeTimer.current = null
}
scrollToActive(false, true)
// 再等两帧,确保条件渲染后的 offsetTop 已稳定
scrollToActive(false)
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToActive(false, true))
requestAnimationFrame(() => scrollToActive(false))
})
}, [scrollToActive])
// 换歌/重载歌词:重置浏览锁并回到播放行
useLayoutEffect(() => {
recenterAfterLayout()
}, [lines, showZh, showRoma, recenterAfterLayout])
userScrollLock.current = false
if (resumeTimer.current) {
clearTimeout(resumeTimer.current)
resumeTimer.current = null
}
scrollToActive(false)
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToActive(false))
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lines])
// 语言行高变化:浏览中只重算焦点,不清锁、不回跳播放行
useLayoutEffect(() => {
if (userScrollLock.current) {
requestAnimationFrame(() => updateVisualFocusFromViewport())
return
}
scrollToActive(false)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!userScrollLock.current) scrollToActive(false)
})
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showZh, showRoma])
// 播放行推进时跟唱(浏览锁生效则完全不滚动)
useEffect(() => {
scrollToActive(true)
}, [activeIdx, scrollToActive])
useEffect(() => {
const onResize = (): void => scrollToActive(false, true)
const onResize = (): void => {
if (userScrollLock.current) return
scrollToActive(false)
}
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
if (resumeTimer.current) clearTimeout(resumeTimer.current)
cancelScroll.current?.()
if (visualFocusRaf.current) cancelAnimationFrame(visualFocusRaf.current)
}
}, [scrollToActive])
/** 用户手动滚动时暂停自动跟唱,停止操作数秒后再恢复 */
const onUserScrollIntent = (): void => {
userScrollLock.current = true
const armResumeFollow = useCallback((): void => {
if (resumeTimer.current) clearTimeout(resumeTimer.current)
resumeTimer.current = setTimeout(() => {
userScrollLock.current = false
scrollToActive(true, true)
scrollToActive(true)
}, 4000)
}, [scrollToActive])
/** 用户手动滚动:打断跟唱,滚动过程中禁止回跳播放行 */
const onUserScrollIntent = (): void => {
cancelScroll.current?.()
programmaticScroll.current = false
userScrollLock.current = true
updateVisualFocusFromViewport()
armResumeFollow()
}
/** 滚动过程中持续按视口中线更新清晰焦点rAF 节流) */
const onViewportScroll = (): void => {
if (programmaticScroll.current) return
if (!userScrollLock.current) onUserScrollIntent()
else armResumeFollow()
if (visualFocusRaf.current) return
visualFocusRaf.current = requestAnimationFrame(() => {
visualFocusRaf.current = 0
updateVisualFocusFromViewport()
})
}
const cover = current ? buildFileUrl(httpBase, current.baseUrl, current.coverPath) : ''
const collapse = (): void => {
if (leaving || closingRef.current) return
setSheetSettled(false)
setLeaving(true)
}
@@ -211,61 +320,95 @@ export default function Lyric(): JSX.Element {
play()
}
const langToggles = (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
flushSync(() => setShowRoma((v) => !v))
unlockAndRecenterPlaying()
}}
className={clsx(
'no-drag flex h-8 items-center rounded-full px-3 text-xs font-medium transition-colors',
showRoma
? 'bg-[var(--text)] text-[var(--bg)]'
: 'bg-black/5 text-[var(--text-secondary)] dark:bg-white/10'
)}
>
</button>
<button
type="button"
onClick={() => {
flushSync(() => setShowZh((v) => !v))
unlockAndRecenterPlaying()
}}
className={clsx(
'no-drag flex h-8 items-center rounded-full px-3 text-xs font-medium transition-colors',
showZh
? 'bg-[var(--text)] text-[var(--bg)]'
: 'bg-black/5 text-[var(--text-secondary)] dark:bg-white/10'
)}
>
</button>
</div>
)
return (
<motion.div
className="fixed inset-x-0 top-0 bottom-20 z-40 flex flex-col bg-[var(--bg)]"
initial={{ y: '100%' }}
animate={{ y: leaving ? '100%' : 0 }}
transition={{ duration: 0.38, ease: [0.32, 0.72, 0, 1] }}
onAnimationComplete={() => {
if (leaving) finishClose()
}}
>
{/* 顶栏:整条 drag仅按钮 no-drag勿给全宽层加 no-drag否则 Electron 无法拖窗) */}
<div className="relative h-[72px] w-full shrink-0">
<div className="drag absolute inset-0" />
<div className="pointer-events-none absolute inset-x-0 bottom-2 top-auto flex h-10 items-center justify-between px-8">
<button
type="button"
onClick={collapse}
className="apple-btn-ghost pointer-events-auto h-9 min-w-[88px] px-4"
>
<ChevronDown size={16} />
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
flushSync(() => setShowRoma((v) => !v))
recenterAfterLayout()
}}
className={clsx(
'no-drag pointer-events-auto flex h-8 items-center rounded-full px-3 text-xs font-medium transition-colors',
showRoma
? 'bg-[var(--text)] text-[var(--bg)]'
: 'bg-black/5 text-[var(--text-secondary)] dark:bg-white/10'
)}
>
</button>
<button
type="button"
onClick={() => {
flushSync(() => setShowZh((v) => !v))
recenterAfterLayout()
}}
className={clsx(
'no-drag pointer-events-auto flex h-8 items-center rounded-full px-3 text-xs font-medium transition-colors',
showZh
? 'bg-[var(--text)] text-[var(--bg)]'
: 'bg-black/5 text-[var(--text-secondary)] dark:bg-white/10'
)}
>
</button>
</div>
<>
{/*
Electron祖先带 CSS transform 时 -webkit-app-region:drag 会失效。
framer-motion 的 y 动画会挂 transform故在面板外另铺一条拖拽热区避开左右按钮
*/}
{sheetSettled && !leaving ? (
<div
aria-hidden
className="drag fixed top-0 z-[45] h-[52px]"
style={{ left: isMac ? 130 : 170, right: 170 }}
/>
) : null}
<motion.div
className="fixed inset-x-0 top-0 bottom-20 z-40 flex flex-col bg-[var(--bg)]"
initial={{ y: '100%' }}
animate={{ y: leaving ? '100%' : 0 }}
transition={{ duration: 0.38, ease: [0.32, 0.72, 0, 1] }}
transformTemplate={
sheetSettled && !leaving ? () => 'none' : (_t, generated) => generated
}
onAnimationComplete={() => {
if (leaving) finishClose()
else setSheetSettled(true)
}}
>
{/* 顶栏:仅按钮 no-drag中间拖拽依赖上方独立热区 + 去 transform */}
<div className="drag relative h-[72px] w-full shrink-0 select-none">
{isMac ? (
<div className="flex h-12 items-center justify-between pr-8 pt-1">
{/* trafficLightPosition x:16 → 绿灯右侧再留一点间距 */}
<button
type="button"
onClick={collapse}
title="收起"
className="no-drag ml-[92px] grid h-7 w-7 place-items-center rounded-full bg-black/5 text-[var(--text-secondary)] transition-colors hover:bg-black/10 hover:text-[var(--text)] dark:bg-white/10 dark:hover:bg-white/15"
>
<ChevronDown size={16} strokeWidth={2.25} />
</button>
{langToggles}
</div>
) : (
<div className="absolute inset-x-0 bottom-2 flex h-10 items-center justify-between px-8">
<button
type="button"
onClick={collapse}
className="apple-btn-ghost h-9 min-w-[88px] px-4"
>
<ChevronDown size={16} />
</button>
{langToggles}
</div>
)}
</div>
</div>
{/*
约定:
@@ -304,8 +447,8 @@ export default function Lyric(): JSX.Element {
<div className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#c0c0c0] shadow" />
</div>
</div>
<div className="mt-8 flex w-full items-start justify-center gap-2 px-3">
<h3 className="min-w-0 max-w-full break-words text-center text-2xl font-bold">
<div className="mt-8 flex w-max max-w-none items-center justify-center gap-2 px-3">
<h3 className="whitespace-nowrap text-center text-2xl font-bold">
{current?.musicName ?? '未在播放'}
</h3>
{current && (
@@ -313,7 +456,7 @@ export default function Lyric(): JSX.Element {
type="button"
title={isLoved ? '取消喜欢' : '添加到我喜欢'}
onClick={() => toggleLove(current.musicUId)}
className="no-drag mt-1 shrink-0 rounded-full p-1.5 transition-colors hover:bg-black/5 dark:hover:bg-white/10"
className="no-drag shrink-0 rounded-full p-1.5 transition-colors hover:bg-black/5 dark:hover:bg-white/10"
>
<Heart
size={20}
@@ -333,6 +476,7 @@ export default function Lyric(): JSX.Element {
ref={viewportRef}
onWheel={onUserScrollIntent}
onTouchStart={onUserScrollIntent}
onScroll={onViewportScroll}
className="lyric-mask soft-scroll-native relative min-h-0 min-w-0 overflow-y-auto overflow-x-hidden px-2"
>
{lines.length === 0 ? (
@@ -342,22 +486,25 @@ export default function Lyric(): JSX.Element {
) : (
<div className="flex w-full flex-col py-[28vh]">
{lines.map((line, i) => {
const active = i === activeIdx
const dist = activeIdx < 0 ? i : Math.abs(i - activeIdx)
const v = lineVisual(dist, active)
const playing = i === activeIdx
// 字号/缩放只跟播放行;清晰度(模糊+透明度)跟视口中线焦点
const sizeDist = activeIdx < 0 ? i : Math.abs(i - activeIdx)
const focusDist = Math.abs(i - visualFocusIdx)
const sizeV = lineVisual(sizeDist, playing)
const focusV = lineVisual(focusDist, i === visualFocusIdx)
return (
<motion.div
key={i}
data-line={i}
title="击跳转到此处播放"
onDoubleClick={() => seekToLine(line.time)}
title="击跳转到此处播放"
onClick={() => seekToLine(line.time)}
initial={false}
animate={{
opacity: v.opacity,
scale: v.scale,
y: v.y,
filter: `blur(${v.blur}px)`
opacity: focusV.opacity,
scale: sizeV.scale,
y: sizeV.y,
filter: `blur(${focusV.blur}px)`
}}
transition={LINE_SPRING}
style={{ originX: 0, originY: 0.5, willChange: 'transform, opacity, filter' }}
@@ -367,8 +514,8 @@ export default function Lyric(): JSX.Element {
<motion.p
initial={false}
animate={{
fontSize: v.jpSize,
color: active ? 'var(--text)' : 'var(--text-secondary)'
fontSize: sizeV.jpSize,
color: playing ? 'var(--text)' : 'var(--text-secondary)'
}}
transition={SIZE_SPRING}
className="font-bold leading-snug tracking-tight"
@@ -379,7 +526,7 @@ export default function Lyric(): JSX.Element {
{showRoma && line.roma && (
<motion.p
initial={false}
animate={{ fontSize: v.subSize }}
animate={{ fontSize: sizeV.subSize }}
transition={SIZE_SPRING}
className="mt-1 font-medium text-[var(--text-secondary)]"
>
@@ -389,7 +536,7 @@ export default function Lyric(): JSX.Element {
{showZh && line.zh && (
<motion.p
initial={false}
animate={{ fontSize: active ? v.subSize + 1 : v.subSize }}
animate={{ fontSize: playing ? sizeV.subSize + 1 : sizeV.subSize }}
transition={SIZE_SPRING}
className="mt-1 font-medium text-[var(--text-secondary)]"
>
@@ -403,6 +550,7 @@ export default function Lyric(): JSX.Element {
)}
</div>
</div>
</motion.div>
</motion.div>
</>
)
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
HardDrive,
Palette,
@@ -48,11 +48,14 @@ export default function Settings(): JSX.Element {
port: 0
})
const [dataVersion, setDataVersion] = useState('')
const [librarySyncedAt, setLibrarySyncedAt] = useState('')
const [syncing, setSyncing] = useState(false)
const [syncMsg, setSyncMsg] = useState('')
const [remoteUrl, setRemoteUrl] = useState('')
/** 本地 / 远程互斥:决定封面与音频从哪加载 */
/** 设置页当前查看的 tab可与实际启用来源不一致 */
const [sourceMode, setSourceMode] = useState<'local' | 'remote'>('local')
/** 实际生效的封面/音频来源(已持久化) */
const [activeMediaSource, setActiveMediaSource] = useState<'local' | 'remote'>('local')
const [checking, setChecking] = useState(false)
const [update, setUpdate] = useState<{
version: string
@@ -62,26 +65,74 @@ export default function Settings(): JSX.Element {
url: string
error?: boolean
} | null>(null)
const [sourceBusy, setSourceBusy] = useState(false)
/** 端口回退 / 操作失败等橙色提示(约 5s 后消失) */
const [sourceWarn, setSourceWarn] = useState('')
const sourceWarnTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const showSourceWarn = (msg: string): void => {
if (!msg) return
setSourceWarn(msg)
if (sourceWarnTimer.current) clearTimeout(sourceWarnTimer.current)
sourceWarnTimer.current = setTimeout(() => {
setSourceWarn('')
sourceWarnTimer.current = null
}, 5000)
}
const showPortFallbackWarn = (requested: number, actual: number): void => {
if (!actual || actual === requested) return
showSourceWarn(
`端口 ${requested} 不可用(可能被占用),已改用 ${actual}。可关闭占用进程后重新「应用并启动」,或继续使用当前端口。`
)
}
useEffect(() => {
const load = async (): Promise<void> => {
setRoot((await window.api.store.get<string>('serverPath')) || '')
setPort((await window.api.store.get<number>('serverPort')) || 10000)
setServerStatus(await window.api.http.status())
const savedPort = (await window.api.store.get<number>('serverPort')) || 10000
setPort(savedPort)
const status = await window.api.http.status()
setServerStatus(status)
// 启动时若已因占用改用其它端口,进入设置页也提示一次
if (status.running && status.port && status.port !== savedPort) {
showPortFallbackWarn(savedPort, status.port)
}
setDataVersion((await window.api.library.dataVersion()) || '未同步')
setLibrarySyncedAt((await window.api.library.syncedAt()) || '')
const url = (await window.api.store.get<string>('url')) || ''
setRemoteUrl(url)
setSourceMode(url.trim() ? 'remote' : 'local')
const savedMode = await window.api.store.get<'local' | 'remote'>('mediaSource')
// 兼容旧配置:无 mediaSource 时按是否填写过远程地址推断
const mode = savedMode || (url.trim() ? 'remote' : 'local')
setActiveMediaSource(mode)
setSourceMode(mode)
}
load()
return () => {
if (sourceWarnTimer.current) clearTimeout(sourceWarnTimer.current)
}
// showPortFallbackWarn 仅用稳定 setter/ref
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const formatSyncedAt = (iso: string): string => {
if (!iso) return '尚未拉取'
const t = Date.parse(iso)
if (!Number.isFinite(t)) return '尚未拉取'
return new Date(t).toLocaleString('zh-CN', { hour12: false })
}
const syncLibrary = async (): Promise<void> => {
setSyncing(true)
setSyncMsg('正在从云端拉取曲库数据…')
try {
const r = await window.api.library.sync()
const syncedAt = r.syncedAt || new Date().toISOString()
// 双写确保持久化(防止主进程旧代码未写入)
await window.api.store.set('librarySyncedAt', syncedAt)
setDataVersion(String(r.version))
setLibrarySyncedAt(syncedAt)
setSyncMsg(`更新成功:${r.albums} 张专辑 / ${r.musics} 首歌曲`)
await loadLibrary({ force: true })
} catch (e) {
@@ -101,67 +152,54 @@ export default function Settings(): JSX.Element {
}
}
const [sourceBusy, setSourceBusy] = useState(false)
const [sourceMsg, setSourceMsg] = useState('')
const chooseDir = async (): Promise<void> => {
const dir = await window.api.dialog.openDir()
if (dir) {
setRoot(dir)
await window.api.store.set('serverPath', dir)
setSourceMsg('已选择目录,点击「应用并启动」即可提供封面与音频')
}
}
/** 切到本地:清空远程 url与远程互斥 */
const switchToLocal = async (): Promise<void> => {
/** 切到本地 tab仅切换界面等「应用并启动」成功后再改用来源 */
const switchToLocal = (): void => {
if (sourceMode === 'local') return
setSourceMode('local')
setSourceMsg('')
await window.api.store.set('url', '')
setRemoteUrl('')
await usePlayerStore.getState().init()
setSourceMsg('已切换为本地文件服务(远程地址已停用)')
}
/** 切到远程:停止本地 HTTP与本地互斥 */
const switchToRemote = async (): Promise<void> => {
/** 切到远程 tab仅切换界面等「保存并启用」再停本地服务并生效 */
const switchToRemote = (): void => {
if (sourceMode === 'remote') return
setSourceMode('remote')
setSourceMsg('')
try {
await window.api.http.stop()
setServerStatus(await window.api.http.status())
} catch (e) {
console.error(e)
}
setSourceMsg('已切换为远程地址,请填写并保存;本地服务已停止')
}
/** 本地:清远程 → 存目录/端口 → 启动服务 */
/** 本地:存目录/端口 → 启动服务(不清空远程地址) */
const applyAndStart = async (): Promise<void> => {
if (!root) {
setSourceMsg('请先选择本地曲库目录')
showSourceWarn('请先选择本地曲库目录')
return
}
setSourceBusy(true)
setSourceMsg('')
try {
await window.api.store.set('url', '')
setRemoteUrl('')
await window.api.store.set('mediaSource', 'local')
setActiveMediaSource('local')
await window.api.store.set('serverPath', root)
await window.api.store.set('serverPort', port)
const requestedPort = port
const status = await window.api.http.start(root, port)
setServerStatus(status)
// 本地基址立刻生效(远程 url 仍保留在 store不能等 init 误读)
if (status.port) {
usePlayerStore.setState({ httpBase: `http://127.0.0.1:${status.port}` })
}
await usePlayerStore.getState().init()
setSourceMsg(
status.running
? `本地服务已就绪 · http://127.0.0.1:${status.port}`
: '启动未成功,请检查目录与端口'
)
if (status.running && status.port !== requestedPort) {
showPortFallbackWarn(requestedPort, status.port)
} else if (!status.running) {
showSourceWarn('启动未成功,请检查目录与端口')
}
} catch (e) {
console.error(e)
setSourceMsg('启动失败,请检查目录是否可访问、端口是否被占用')
showSourceWarn('启动失败,请检查目录是否可访问、端口是否被占用')
} finally {
setSourceBusy(false)
}
@@ -171,14 +209,18 @@ export default function Settings(): JSX.Element {
const saveRemoteUrl = async (): Promise<void> => {
const url = remoteUrl.trim()
if (!url) {
setSourceMsg('请填写远程曲库地址')
showSourceWarn('请填写远程曲库地址')
return
}
setSourceBusy(true)
setSourceMsg('')
try {
await window.api.store.set('url', url.endsWith('/') ? url : `${url}/`)
setRemoteUrl(url.endsWith('/') ? url : `${url}/`)
const normalized = url.endsWith('/') ? url : `${url}/`
await window.api.store.set('url', normalized)
await window.api.store.set('mediaSource', 'remote')
setRemoteUrl(normalized)
setActiveMediaSource('remote')
// 先切播放基址,再停本地服务,避免中间态仍用已失效的 127.0.0.1
usePlayerStore.setState({ httpBase: normalized })
try {
await window.api.http.stop()
setServerStatus(await window.api.http.status())
@@ -186,19 +228,32 @@ export default function Settings(): JSX.Element {
/* ignore */
}
await usePlayerStore.getState().init()
setSourceMsg('已保存远程地址,播放将使用该来源')
} catch (e) {
console.error(e)
setSourceMsg('保存失败')
showSourceWarn('保存失败')
} finally {
setSourceBusy(false)
}
}
const localReady = sourceMode === 'local' && serverStatus.running && !!root
const localEndpoint = serverStatus.running
? `http://127.0.0.1:${serverStatus.port}`
: ''
const localReady = activeMediaSource === 'local' && serverStatus.running && !!root
const localEndpoint =
localReady && serverStatus.port ? `http://127.0.0.1:${serverStatus.port}` : ''
const localStatusLabel = localReady
? '本地已启用'
: activeMediaSource === 'remote'
? '远程使用中,本地尚未启用'
: root
? serverStatus.running
? '服务在跑,来源未切到本地'
: '目录已选,服务未启动'
: '尚未配置本地曲库'
const remoteEnabled = activeMediaSource === 'remote' && !!remoteUrl.trim()
const remoteStatusLabel = remoteEnabled
? '远程已启用'
: remoteUrl.trim()
? '已填写,当前未启用'
: '尚未配置远程地址'
return (
<div className="max-w-2xl">
@@ -214,9 +269,10 @@ export default function Settings(): JSX.Element {
<RefreshCw size={14} className={clsx(syncing && 'animate-spin')} />
{syncing ? '同步中…' : '从云端更新曲库'}
</button>
<span className="text-xs text-[var(--text-secondary)]">
{dataVersion}
</span>
<div className="min-w-0 text-xs text-[var(--text-secondary)]">
<p>{dataVersion}</p>
<p className="mt-0.5">{formatSyncedAt(librarySyncedAt)}</p>
</div>
</div>
{syncMsg && <p className="mt-3 text-xs text-apple-blue">{syncMsg}</p>}
<p className="mt-3 text-xs text-[var(--text-secondary)]">
@@ -229,7 +285,7 @@ export default function Settings(): JSX.Element {
<div className="mb-5 flex rounded-xl bg-black/5 p-1 dark:bg-white/10">
<button
type="button"
onClick={() => void switchToLocal()}
onClick={switchToLocal}
className={clsx(
'flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-medium transition-all',
sourceMode === 'local'
@@ -241,7 +297,7 @@ export default function Settings(): JSX.Element {
</button>
<button
type="button"
onClick={() => void switchToRemote()}
onClick={switchToRemote}
className={clsx(
'flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-medium transition-all',
sourceMode === 'remote'
@@ -255,12 +311,15 @@ export default function Settings(): JSX.Element {
{sourceMode === 'local' ? (
<>
{sourceWarn && (
<p className="mb-2 text-xs text-apple-orange">{sourceWarn}</p>
)}
<div
className={clsx(
'mb-5 flex items-center gap-2 rounded-xl px-3.5 py-2.5 text-sm',
localReady
? 'bg-apple-green/10 text-apple-green'
: root
: activeMediaSource === 'remote' || root
? 'bg-apple-orange/10 text-apple-orange'
: 'bg-black/5 text-[var(--text-secondary)] dark:bg-white/10'
)}
@@ -269,9 +328,7 @@ export default function Settings(): JSX.Element {
size={8}
className={clsx('fill-current', localReady ? 'text-apple-green' : 'opacity-50')}
/>
<span className="font-medium">
{localReady ? '服务运行中' : root ? '目录已选,服务未启动' : '尚未配置本地曲库'}
</span>
<span className="font-medium">{localStatusLabel}</span>
{localEndpoint && (
<code className="ml-auto truncate text-xs opacity-90">{localEndpoint}</code>
)}
@@ -320,14 +377,37 @@ 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
</p>
</>
) : (
<>
<div className="mb-5 flex items-center gap-2 rounded-xl bg-apple-blue/10 px-3.5 py-2.5 text-sm text-apple-blue">
<Link2 size={14} />
<span className="font-medium">使 HTTP </span>
{sourceWarn && (
<p className="mb-2 text-xs text-apple-orange">{sourceWarn}</p>
)}
<div
className={clsx(
'mb-5 flex items-center gap-2 rounded-xl px-3.5 py-2.5 text-sm',
remoteEnabled
? 'bg-apple-green/10 text-apple-green'
: remoteUrl.trim()
? 'bg-apple-orange/10 text-apple-orange'
: 'bg-black/5 text-[var(--text-secondary)] dark:bg-white/10'
)}
>
<Circle
size={8}
className={clsx(
'fill-current',
remoteEnabled ? 'text-apple-green' : 'opacity-50'
)}
/>
<span className="font-medium">{remoteStatusLabel}</span>
{remoteEnabled && (
<code className="ml-auto max-w-[55%] truncate text-xs opacity-90">
{remoteUrl}
</code>
)}
</div>
<div className="flex items-center gap-2">
<input
@@ -342,16 +422,14 @@ export default function Settings(): JSX.Element {
disabled={sourceBusy}
className={clsx('apple-btn-primary shrink-0', sourceBusy && 'opacity-50')}
>
{remoteEnabled ? '保存并更新' : '保存并启用'}
</button>
</div>
<p className="mt-3 text-xs leading-relaxed text-[var(--text-secondary)]">
</p>
</>
)}
{sourceMsg && <p className="mt-3 text-xs text-apple-blue">{sourceMsg}</p>}
</Section>
<Section icon={Palette} title="外观">

View File

@@ -661,7 +661,17 @@ export default function Transfer(): JSX.Element {
onChange={() => toggleSel(m.musicUId)}
className="h-3.5 w-3.5 shrink-0 accent-apple-blue"
/>
<span className="min-w-0 flex-1 truncate text-xs">{m.musicName}</span>
<span className="flex min-w-0 flex-1 items-baseline gap-1.5 text-xs">
<span className="truncate font-medium">{m.musicName}</span>
{m.artist ? (
<span
className="min-w-0 shrink truncate text-[var(--text-secondary)]"
title={m.artist}
>
{m.artist}
</span>
) : null}
</span>
{m.recommend ? (
<span className="shrink-0 rounded bg-apple-green/15 px-1.5 py-0.5 text-[10px] text-apple-green">

View File

@@ -97,10 +97,54 @@ if (typeof window !== 'undefined') {
;(window as unknown as { __flushPlayback?: () => Promise<void> }).__flushPlayback = flushPlayback
}
const FADE_MS = 800
let fadeRaf: number | null = null
let fadeGen = 0
function cancelVolumeFade(): void {
fadeGen += 1
if (fadeRaf != null) {
cancelAnimationFrame(fadeRaf)
fadeRaf = null
}
}
/** 音量渐变;不改 store 里的用户音量,只动 audio.volume */
function fadeAudioVolume(to: number, ms: number, onDone?: () => void): void {
cancelVolumeFade()
const gen = fadeGen
const from = audio.volume
const start = performance.now()
const step = (now: number): void => {
if (gen !== fadeGen) return
const t = Math.min(1, (now - start) / Math.max(1, ms))
const eased = t * t * (3 - 2 * t)
audio.volume = Math.min(1, Math.max(0, from + (to - from) * eased))
if (t < 1) {
fadeRaf = requestAnimationFrame(step)
return
}
fadeRaf = null
audio.volume = Math.min(1, Math.max(0, to))
onDone?.()
}
fadeRaf = requestAnimationFrame(step)
}
function applyTrack(music: Music, httpBase: string, autoplay: boolean): void {
cancelVolumeFade()
const vol = usePlayerStore.getState().volume
audio.volume = vol
audio.src = buildFileUrl(httpBase, music.baseUrl, music.musicPath)
if (autoplay) {
audio.play().catch(() => undefined)
// 切歌也做短渐入,避免硬起
audio.volume = 0
void audio
.play()
.then(() => fadeAudioVolume(vol, FADE_MS))
.catch(() => {
audio.volume = vol
})
}
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
@@ -159,7 +203,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
const vol = (await window.api.store.get<number>('volume')) ?? 0.8
const mode = (await window.api.store.get<PlayMode>('playMode')) ?? 'order'
const remoteUrl = (await window.api.store.get<string>('url')) || ''
const httpBase = remoteUrl || (status.port ? `http://127.0.0.1:${status.port}` : '')
const mediaSource =
(await window.api.store.get<'local' | 'remote'>('mediaSource')) ||
(remoteUrl.trim() ? 'remote' : 'local')
// 必须看 mediaSource远程地址会在切本地时保留不能再写 remoteUrl || local
const httpBase =
mediaSource === 'remote' && remoteUrl.trim()
? remoteUrl
: status.port
? `http://127.0.0.1:${status.port}`
: ''
audio.volume = vol
set({ httpBase, volume: vol, mode })
@@ -177,6 +230,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
if (action === 'playpause') get().toggle()
else if (action === 'next') get().next()
else if (action === 'prev') get().prev()
else if (action === 'seek-forward') {
const { progress, duration } = get()
const max = Number.isFinite(duration) && duration > 0 ? duration : progress + 5
get().seek(Math.min(max, progress + 5))
} else if (action === 'seek-backward') {
get().seek(Math.max(0, get().progress - 5))
}
})
if ('mediaSession' in navigator) {
@@ -250,7 +310,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
if (index < 0 || index >= queue.length) return
const nextQueue = queue.filter((_, i) => i !== index)
if (nextQueue.length === 0) {
cancelVolumeFade()
audio.pause()
audio.volume = get().volume
audio.removeAttribute('src')
set({ queue: [], current: null, index: -1, isPlaying: false, progress: 0 })
persistPlayback(true)
@@ -267,20 +329,35 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
},
clearQueue: () => {
cancelVolumeFade()
audio.pause()
audio.volume = get().volume
audio.removeAttribute('src')
set({ queue: [], current: null, index: -1, isPlaying: false, progress: 0 })
persistPlayback(true)
},
play: () => {
audio.play().catch(() => undefined)
const target = get().volume
cancelVolumeFade()
audio.volume = 0
void audio
.play()
.then(() => fadeAudioVolume(target, FADE_MS))
.catch(() => {
audio.volume = target
})
set({ isPlaying: true })
},
pause: () => {
audio.pause()
const target = get().volume
cancelVolumeFade()
set({ isPlaying: false })
persistPlayback(true)
fadeAudioVolume(0, FADE_MS, () => {
audio.pause()
audio.volume = target
persistPlayback(true)
})
},
toggle: () => (get().isPlaying ? get().pause() : get().play()),
@@ -289,8 +366,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
if (queue.length === 0) return
let nextIndex = index
if (mode === 'single') {
const vol = get().volume
cancelVolumeFade()
audio.currentTime = 0
audio.play().catch(() => undefined)
audio.volume = 0
void audio
.play()
.then(() => fadeAudioVolume(vol, FADE_MS))
.catch(() => {
audio.volume = vol
})
return
} else if (mode === 'shuffle') {
nextIndex = Math.floor(Math.random() * queue.length)
@@ -322,6 +407,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
},
setVolume: (v) => {
cancelVolumeFade()
audio.volume = v
window.api.store.set('volume', v)
set({ volume: v })

View File

@@ -24,6 +24,7 @@ export const IPC = {
// ---- 曲库同步OSS data.json 元数据) ----
LIBRARY_SYNC: 'library:sync',
LIBRARY_DATA_VERSION: 'library:dataVersion',
LIBRARY_SYNCED_AT: 'library:syncedAt',
// ---- 转码iOS flac -> wav ----
CONVERT_START: 'convert:start',
@@ -64,6 +65,9 @@ export const IPC = {
LYRIC_TOGGLE: 'lyric:toggle',
LYRIC_UPDATE: 'lyric:update',
LYRIC_SET_IGNORE_MOUSE: 'lyric:setIgnoreMouse',
LYRIC_POINTER_INSIDE: 'lyric:pointerInside',
/** 主进程推送:光标是否在桌面歌词窗口内 */
LYRIC_HOVER: 'lyric:hover',
LYRIC_STATE: 'lyric:state',
// ---- 更新 ----

View File

@@ -3,6 +3,7 @@
"include": [
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/renderer/src/**/*.d.ts",
"src/preload/*.d.ts",
"src/shared/**/*"
],