adapte macos
24
.env.example
Normal 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=
|
||||
|
||||
# --- 公证凭证(三选一)---
|
||||
|
||||
# 方案 A:Apple ID + App 专用密码(appleid.apple.com → 登录与安全 → App 专用密码)
|
||||
APPLE_ID=
|
||||
APPLE_APP_SPECIFIC_PASSWORD=
|
||||
APPLE_TEAM_ID=
|
||||
|
||||
# 方案 B:App 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
@@ -7,3 +7,8 @@ out
|
||||
.idea
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.env
|
||||
.env.local
|
||||
*.p8
|
||||
*.p12
|
||||
AuthKey_*.p8
|
||||
|
||||
1
.npmrc
@@ -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
|
||||
|
||||
21
build/entitlements.mac.plist
Normal 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
BIN
build/icon.ico
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
build/icon.png
Normal file
|
After Width: | Height: | Size: 759 KiB |
23
build/icon.svg
Normal 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
|
After Width: | Height: | Size: 2.0 MiB |
BIN
build/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
build/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
build/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
build/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
build/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
build/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
build/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 759 KiB |
BIN
build/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
build/icons/96x96.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
@@ -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
12
package.json
@@ -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
BIN
resources/icon.ico
Normal file
|
After Width: | Height: | Size: 147 KiB |
23
resources/icon.svg
Normal 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 |
36
scripts/patch-electron-dev-name.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 开发态跑的是 node_modules 里的 Electron.app,菜单栏名称来自 Info.plist。
|
||||
* 把 CFBundleName / CFBundleDisplayName 改成产品名(仅 macOS;npm 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}`)
|
||||
39
scripts/run-electron-builder.mjs
Normal 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)
|
||||
@@ -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 图标;菜单栏名称仍会显示 Electron(Info.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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,8 +69,35 @@ export interface FileServerStatus {
|
||||
root: string
|
||||
}
|
||||
|
||||
export async function startFileServer(root: string, preferredPort: number): Promise<FileServerStatus> {
|
||||
await stopFileServer()
|
||||
async function stopFileServerUnlocked(): Promise<void> {
|
||||
const server = getServer()
|
||||
setServerState(null, 0, '')
|
||||
if (!server) return
|
||||
|
||||
// 先掐断连接,否则 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 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') {
|
||||
@@ -56,34 +114,25 @@ export async function startFileServer(root: string, preferredPort: number): Prom
|
||||
// 与被中断的请求,容易把连接池打满导致图片全部加载失败。
|
||||
application.use(express.static(root, { maxAge: '7d', etag: true, lastModified: true }))
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server = http.createServer(application)
|
||||
const 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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function stopFileServer(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (server) {
|
||||
server.close(() => {
|
||||
server = null
|
||||
currentPort = 0
|
||||
currentRoot = ''
|
||||
resolve()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(port, '0.0.0.0', () => resolve())
|
||||
})
|
||||
} else {
|
||||
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 ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface SyncResult {
|
||||
version: number
|
||||
albums: number
|
||||
musics: number
|
||||
/** 拉取完成时间(由 IPC 层写入并返回) */
|
||||
syncedAt?: string
|
||||
}
|
||||
|
||||
export async function syncLibrary(): Promise<SyncResult> {
|
||||
|
||||
53
src/main/services/mediaShortcuts.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { globalShortcut, type BrowserWindow } from 'electron'
|
||||
import { sendPlayerControl } from './playerControl'
|
||||
|
||||
/**
|
||||
* 媒体快捷键:
|
||||
* - 播放/暂停:全局 Super+P(macOS ⌘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')
|
||||
}
|
||||
})
|
||||
}
|
||||
16
src/main/services/playerControl.ts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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 || ' '}
|
||||
|
||||
@@ -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,14 +124,41 @@ 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 updateVisualFocusFromViewport = useCallback((): void => {
|
||||
const viewport = viewportRef.current
|
||||
if (!viewport || lines.length === 0) return
|
||||
const activeEl = viewport.querySelector<HTMLElement>(`[data-line="${focusIdx}"]`)
|
||||
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
|
||||
}
|
||||
}
|
||||
setVisualFocusIdx((prev) => (prev === best ? prev : best))
|
||||
}, [lines.length])
|
||||
|
||||
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(
|
||||
@@ -124,61 +166,128 @@ export default function Lyric(): JSX.Element {
|
||||
activeEl.offsetTop - viewport.clientHeight * 0.5 + activeEl.offsetHeight / 2
|
||||
)
|
||||
cancelScroll.current?.()
|
||||
programmaticScroll.current = true
|
||||
if (smooth) {
|
||||
cancelScroll.current = animateScrollTo(viewport, top)
|
||||
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
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
[focusIdx, lines.length]
|
||||
)
|
||||
setVisualFocusIdx(idx)
|
||||
}, [lines.length])
|
||||
|
||||
/** 显隐中文/罗马音后立刻按新高度回中(清掉用户滚动锁定) */
|
||||
const recenterAfterLayout = useCallback((): void => {
|
||||
// 未手动浏览时,视觉焦点跟随播放行
|
||||
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,36 +320,16 @@ export default function Lyric(): JSX.Element {
|
||||
play()
|
||||
}
|
||||
|
||||
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>
|
||||
const langToggles = (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
flushSync(() => setShowRoma((v) => !v))
|
||||
recenterAfterLayout()
|
||||
unlockAndRecenterPlaying()
|
||||
}}
|
||||
className={clsx(
|
||||
'no-drag pointer-events-auto flex h-8 items-center rounded-full px-3 text-xs font-medium transition-colors',
|
||||
'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'
|
||||
@@ -252,10 +341,10 @@ export default function Lyric(): JSX.Element {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
flushSync(() => setShowZh((v) => !v))
|
||||
recenterAfterLayout()
|
||||
unlockAndRecenterPlaying()
|
||||
}}
|
||||
className={clsx(
|
||||
'no-drag pointer-events-auto flex h-8 items-center rounded-full px-3 text-xs font-medium transition-colors',
|
||||
'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'
|
||||
@@ -264,7 +353,61 @@ export default function Lyric(): JSX.Element {
|
||||
中文
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
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>
|
||||
|
||||
{/*
|
||||
@@ -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)]"
|
||||
>
|
||||
@@ -404,5 +551,6 @@ export default function Lyric(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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="外观">
|
||||
|
||||
@@ -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">
|
||||
推荐
|
||||
|
||||
@@ -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 })
|
||||
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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
// ---- 更新 ----
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"include": [
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/renderer/src/**/*.d.ts",
|
||||
"src/preload/*.d.ts",
|
||||
"src/shared/**/*"
|
||||
],
|
||||
|
||||