Compare commits

...

53 Commits

Author SHA1 Message Date
imsyy
cf1490b923 Merge branch 'dev' of https://github.com/imsyy/SPlayer into dev 2025-11-24 18:22:03 +08:00
imsyy
70f342993c feat: 便携版数据自动放置于同级目录 2025-11-24 18:21:56 +08:00
底层用户
961f25d316 Merge pull request #592 from MoYingJi/glob-nocase
feat(file): 大小写不敏感的 FastGlob
2025-11-24 18:13:33 +08:00
imsyy
c6f0d0b75a feat: 歌词文件不区分大小写 2025-11-24 15:24:54 +08:00
imsyy
92ede5739d 🐞 fix: 修复未加载提示 #561 2025-11-24 15:06:39 +08:00
MoYingJi
880fc888e6 feat(file): 大小写不敏感的 FastGlob
将 FastGlob 的 options 参数提取到了 globOpt 函数,在那里统一设置

在 globOpt 里设置了 `caseSensitiveMatch: false`
2025-11-24 13:34:15 +08:00
imsyy
f4c8be7091 🐞 fix: 修复音质不显示 2025-11-23 21:23:05 +08:00
底层用户
68f085c183 Merge pull request #589 from MoYingJi/linux-desktop
fix(build-linux): 规范 Desktop 文件
2025-11-23 00:42:56 +08:00
imsyy
62722d7e2f feat: 添加歌曲音质和特权标签显示选项 2025-11-23 00:41:30 +08:00
MoYingJi
ddd19397b5 fix(build-linux): 规范 Desktop 文件 2025-11-22 03:19:23 +08:00
imsyy
33ab167e52 feat: 支持识别分享链接 2025-11-22 00:25:19 +08:00
imsyy
1b6ebd9c7c feat: 支持解锁配置 2025-11-21 17:41:34 +08:00
imsyy
7721251a98 feat: 优化播放流程 2025-11-21 11:57:53 +08:00
底层用户
320047ca9c Merge pull request #586 from SUBearH/SUBearH-patch-2
为歌单页面添加歌单创建时间的显示
2025-11-21 11:54:55 +08:00
底层用户
73be8d8657 Merge pull request #585 from SUBearH/SUBearH-patch-1
Increase max badge value from 999 to 9999
2025-11-21 11:53:29 +08:00
imsyy
d527b076dc 🐞 fix: 优化播放处理 2025-11-21 11:03:36 +08:00
SUBear
7bf9c6d7bc Merge pull request #1 from SUBearH/SUBearH-patch-2-1
Fix conditional rendering for createTime in playlist
2025-11-21 02:51:05 +08:00
SUBear
4dfd897401 Fix conditional rendering for createTime in playlist 2025-11-21 02:48:29 +08:00
SUBear
f0a6526fd1 Fix conditional rendering for createTime display 2025-11-21 02:47:02 +08:00
SUBear
6d4f78413b Increase max badge value from 999 to 9999 2025-11-21 02:36:59 +08:00
imsyy
8866996e5b 🐞 fix: 再次修复过慢的歌词请求 2025-11-20 23:23:33 +08:00
imsyy
5d21709c58 🌈 style: 简介改为弹窗 & 优化简介换行 2025-11-20 21:58:12 +08:00
底层用户
54d77d08eb Merge pull request #583 from MoYingJi/fix
fix(playlist): 歌单描述有换行时并没有正确换行
2025-11-20 20:37:10 +08:00
MoYingJi
bd25a8fe2e fix(playlist): 歌单描述有换行时并没有正确换行 2025-11-20 20:30:14 +08:00
imsyy
f0270a2fb0 feat: 添加交流群 2025-11-20 16:50:35 +08:00
imsyy
3ebfccdfcc feat: 新增音源-gequbao 2025-11-20 14:47:51 +08:00
imsyy
c29f2ed0a0 🌈 style: 优化部分样式 2025-11-20 14:23:17 +08:00
imsyy
6caf99da09 feat(player): 添加播放状态信息显示功能
- 在设置中新增开关控制是否显示播放状态信息
- 在播放器界面添加当前歌曲及歌词状态信息显示
- 优化歌词管理器逻辑,改进TTML歌词处理
- 调整播放器数据组件布局,支持精简显示模式
2025-11-20 00:14:05 +08:00
底层用户
ad27d1eaea Merge pull request #578 from MoYingJi/feat
fix(local-lyric): 现在会匹配带前缀的歌词文件名
2025-11-19 22:13:30 +08:00
MoYingJi
8846d7f669 fix(local-lyric): 现在会匹配带前缀的歌词文件名
也添加了注释解释 `{,*.}` 的作用,避免被误删
2025-11-19 21:36:48 +08:00
imsyy
807b72ed9e 🌈 style: 优化样式 2025-11-19 18:32:39 +08:00
imsyy
d89be488e2 修复列表问题 #569 #568 2025-11-19 14:17:22 +08:00
imsyy
4bf986b763 feat: 桌面歌词支持 TTML 2025-11-19 10:56:55 +08:00
imsyy
ffb1fcc1ea 🐞 fix: 修正无歌词时条件 2025-11-18 18:19:38 +08:00
imsyy
ea822f91e8 🐞 fix: 修复失败后无法尝试下一曲 2025-11-18 16:12:23 +08:00
imsyy
5e260ffc0d 🐞 fix: 修复进度调节单位错误 2025-11-18 15:29:37 +08:00
imsyy
e3dcde71e8 Merge branch 'dev-lyric' into dev 2025-11-18 15:22:25 +08:00
imsyy
39c35e8a31 🦄 refactor: 重构为毫秒单位 2025-11-18 15:08:15 +08:00
imsyy
772f6552e7 🦄 refactor: 基础适配新格式 2025-11-18 00:16:59 +08:00
imsyy
c9f3553806 🐞 fix: 修复歌词管理引用错误 2025-11-17 18:15:28 +08:00
底层用户
7de1355f18 Merge pull request #573 from MoYingJi/docs-it
docs: 小改 Issue Template
2025-11-17 14:19:59 +08:00
imsyy
392c64f06b feat: 完善歌词模块 2025-11-16 23:35:52 +08:00
MoYingJi
0d66ced637 docs: 小改 Issue Template 2025-11-16 00:29:09 +08:00
底层用户
ae1ee71e75 Merge pull request #567 from MoYingJi/fix-ldc
fix(player): 修复极端状态下播放异常
2025-11-14 09:29:01 +08:00
底层用户
84d9c999eb Merge pull request #566 from MoYingJi/docs-it
docs: 更新 Issue Template
2025-11-14 09:28:14 +08:00
MoYingJi
1a36fbf1d5 fix(player): 修复极端状态下播放异常
由于 `jumpSeek` 现在会减去 `offset`,可能会导致当 `offset` 超出预期时引发一些奇怪的行为
2025-11-14 03:06:29 +08:00
MoYingJi
e0e62cd906 docs: 更新 Issue Template 2025-11-14 01:14:46 +08:00
imsyy
6d367f1fd5 🧪 test: lyric 重构准备 2025-11-14 00:39:19 +08:00
底层用户
2ed6acf934 Merge pull request #563 from MoYingJi/fix-ldc
fix(Lyric): 点击歌词跳转进度未应用偏移
2025-11-14 00:37:56 +08:00
MoYingJi
236ee0a345 style: 补全行尾分号 统一代码风格 2025-11-14 00:06:19 +08:00
MoYingJi
fe0f7a0f25 fix(Lyric): 点击歌词跳转进度未应用偏移 2025-11-13 20:16:55 +08:00
底层用户
8529663ea5 Merge pull request #559 from MoYingJi/feat-lr
feat(lyric): 支持解析本地 LRC 歌词中的音译
2025-11-12 22:29:16 +08:00
MoYingJi
100bae7488 feat(lyric): 支持解析本地 LRC 歌词中的音译
根据已解析歌词中是否有时间相同来判断,因此最先遍历的歌词行会被作为主歌词

若具有翻译或音译,则判断主歌词中是否有翻译,若没有则将此句作为翻译,音译同理

如果出现时间相同的歌词行,第一行会被作为主歌词,第二行翻译,第三行音译,其余舍去
2025-11-12 21:56:04 +08:00
84 changed files with 3066 additions and 3046 deletions

View File

@@ -1,35 +1,75 @@
name: 遇到问题
description: 关于使用过程中遇到的问题
title: 请填写标题
labels: [bug]
body:
- type: input
- type: markdown
attributes:
value: |
---
在此之前,我们默认你已经知道该如何提问。简而言之,**你要精确地描述问题并提供充足的信息**
此外,如果需要,你可以使用 <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd> 打开开发者工具为我们提供信息
- type: checkboxes
validations:
required: false
attributes:
label: "检查清单"
description: |
我们需要了解一些信息,你需要检查下面的检查项 <br />
**这里并不是每一个检查项都必要,根据你的真实情况勾选即可**
options:
- label: "我已检索仓库中所有的 Issues确保我**没有重复提交问题**;或有相似 Issue但我觉得我的情况不包含在那个相似 Issue 之内"
- label: "我已经找到了可以复现这个问题的方法,并且写在了下面的「具体信息」中"
- label: "此问题可以在我的设备和当前环境中**稳定复现**"
- label: "此问题可以在最新版本 (Latest Release) 中复现"
- label: "此问题是在我更新到当前版本后**才**出现的"
- type: dropdown
validations:
required: true
attributes:
label: "是网页端还是客户端"
placeholder: "客户端"
options:
- "客户端"
- "网页端"
default: 0
- type: input
validations:
required: true
attributes:
label: "当前系统环境"
placeholder: "win11"
placeholder: "Windows 11"
- type: input
validations:
required: true
required: false
attributes:
label: "当前 Node.js 及 npm 版本"
placeholder: "v18.16.0 / v9.6.7"
placeholder: "如:v18.16.0 / v9.6.7 (选填)"
- type: input
validations:
required: true
attributes:
label: "当前版本"
placeholder: "v1.0.0"
description: |
填写关于软件里的或 Releases 中版本号即可 <br />
如果是自行构建或从 GitHub Actions 下载的开发版,还需要提供 Commit ID
placeholder: "如v1.0.0"
- type: textarea
id: other
attributes:
label: "具体信息"
description: "请填写完整的复现步骤和遇到的问题,包括但不限于报错信息、控制台输出、网络请求等"
description: |
请填写完整的复现步骤和遇到的问题,还请尽量提供所有可能的信息,提供包括但不限于:
- 分步的复现步骤,可以使用 `1. xxx` (换行) `2. xxx` 的格式
- 截图(如果结合复现步骤还是无法详细表述,甚至可以录屏)
- 开发者工具中的:控制台输出报错、网络请求等
- 出现问题的在线歌曲链接、出现问题的文件(本地歌曲、歌词等)的下载链接
等信息,以更好地帮助我们解决你的问题
placeholder: "请填写具体的复现步骤和遇到的问题"

View File

@@ -30,6 +30,14 @@
- 欢迎各位大佬 `Star` 😍
## 💬 交流群
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=2-cVSf1bE0AvAehCib00qFEFdUvPaJ_k&jump_from=webapi&authKey=1NEhib9+GsmsXVo2rCc0IbRaVHeeRXJJ0gbsyKDcIwDdAzYySOubkFCvkV32+7Cw" target="_blank">
![交流群](/screenshots/welcome.png)
</a>
## 👀 Demo
- [SPlayer](https://music.imsyy.top/)

590
auto-imports.d.ts vendored
View File

@@ -6,300 +6,302 @@
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync']
const computedEager: typeof import('@vueuse/core')['computedEager']
const computedInject: typeof import('@vueuse/core')['computedInject']
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
const controlledRef: typeof import('@vueuse/core')['controlledRef']
const createApp: typeof import('vue')['createApp']
const createEventHook: typeof import('@vueuse/core')['createEventHook']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createRef: typeof import('@vueuse/core')['createRef']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isShallow: typeof import('vue')['isShallow']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
const reactivePick: typeof import('@vueuse/core')['reactivePick']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
const refDebounced: typeof import('@vueuse/core')['refDebounced']
const refDefault: typeof import('@vueuse/core')['refDefault']
const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
const throttledRef: typeof import('@vueuse/core')['throttledRef']
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
const toRaw: typeof import('vue')['toRaw']
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
const unref: typeof import('vue')['unref']
const unrefElement: typeof import('@vueuse/core')['unrefElement']
const until: typeof import('@vueuse/core')['until']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useAnimate: typeof import('@vueuse/core')['useAnimate']
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
const useArraySome: typeof import('@vueuse/core')['useArraySome']
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
const useCached: typeof import('@vueuse/core')['useCached']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCountdown: typeof import('@vueuse/core')['useCountdown']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
const useCycleList: typeof import('@vueuse/core')['useCycleList']
const useDark: typeof import('@vueuse/core')['useDark']
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
const useDialog: typeof import('naive-ui')['useDialog']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDropZone: typeof import('@vueuse/core')['useDropZone']
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
const useFavicon: typeof import('@vueuse/core')['useFavicon']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
const useFocus: typeof import('@vueuse/core')['useFocus']
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
const useFps: typeof import('@vueuse/core')['useFps']
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
const useInterval: typeof import('@vueuse/core')['useInterval']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLink: typeof import('vue-router')['useLink']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
const useNetwork: typeof import('@vueuse/core')['useNetwork']
const useNotification: typeof import('naive-ui')['useNotification']
const useNow: typeof import('@vueuse/core')['useNow']
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const useParallax: typeof import('@vueuse/core')['useParallax']
const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
const useScroll: typeof import('@vueuse/core')['useScroll']
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSlots: typeof import('vue')['useSlots']
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
const useThrottle: typeof import('@vueuse/core')['useThrottle']
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeAgoIntl: typeof import('@vueuse/core')['useTimeAgoIntl']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToNumber: typeof import('@vueuse/core')['useToNumber']
const useToString: typeof import('@vueuse/core')['useToString']
const useToggle: typeof import('@vueuse/core')['useToggle']
const useTransition: typeof import('@vueuse/core')['useTransition']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
const useVibrate: typeof import('@vueuse/core')['useVibrate']
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
const watch: typeof import('vue')['watch']
const watchArray: typeof import('@vueuse/core')['watchArray']
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
const watchDeep: typeof import('@vueuse/core')['watchDeep']
const watchEffect: typeof import('vue')['watchEffect']
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
const watchOnce: typeof import('@vueuse/core')['watchOnce']
const watchPausable: typeof import('@vueuse/core')['watchPausable']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
const whenever: typeof import('@vueuse/core')['whenever']
const EffectScope: typeof import('vue').EffectScope
const asyncComputed: typeof import('@vueuse/core').asyncComputed
const autoResetRef: typeof import('@vueuse/core').autoResetRef
const computed: typeof import('vue').computed
const computedAsync: typeof import('@vueuse/core').computedAsync
const computedEager: typeof import('@vueuse/core').computedEager
const computedInject: typeof import('@vueuse/core').computedInject
const computedWithControl: typeof import('@vueuse/core').computedWithControl
const controlledComputed: typeof import('@vueuse/core').controlledComputed
const controlledRef: typeof import('@vueuse/core').controlledRef
const createApp: typeof import('vue').createApp
const createEventHook: typeof import('@vueuse/core').createEventHook
const createGlobalState: typeof import('@vueuse/core').createGlobalState
const createInjectionState: typeof import('@vueuse/core').createInjectionState
const createReactiveFn: typeof import('@vueuse/core').createReactiveFn
const createRef: typeof import('@vueuse/core').createRef
const createReusableTemplate: typeof import('@vueuse/core').createReusableTemplate
const createSharedComposable: typeof import('@vueuse/core').createSharedComposable
const createTemplatePromise: typeof import('@vueuse/core').createTemplatePromise
const createUnrefFn: typeof import('@vueuse/core').createUnrefFn
const customRef: typeof import('vue').customRef
const debouncedRef: typeof import('@vueuse/core').debouncedRef
const debouncedWatch: typeof import('@vueuse/core').debouncedWatch
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const eagerComputed: typeof import('@vueuse/core').eagerComputed
const effectScope: typeof import('vue').effectScope
const extendRef: typeof import('@vueuse/core').extendRef
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const ignorableWatch: typeof import('@vueuse/core').ignorableWatch
const inject: typeof import('vue').inject
const injectLocal: typeof import('@vueuse/core').injectLocal
const isDefined: typeof import('@vueuse/core').isDefined
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
const manualResetRef: typeof import('@vueuse/core').manualResetRef
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onClickOutside: typeof import('@vueuse/core').onClickOutside
const onDeactivated: typeof import('vue').onDeactivated
const onElementRemoval: typeof import('@vueuse/core').onElementRemoval
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onKeyStroke: typeof import('@vueuse/core').onKeyStroke
const onLongPress: typeof import('@vueuse/core').onLongPress
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onStartTyping: typeof import('@vueuse/core').onStartTyping
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const pausableWatch: typeof import('@vueuse/core').pausableWatch
const provide: typeof import('vue').provide
const provideLocal: typeof import('@vueuse/core').provideLocal
const reactify: typeof import('@vueuse/core').reactify
const reactifyObject: typeof import('@vueuse/core').reactifyObject
const reactive: typeof import('vue').reactive
const reactiveComputed: typeof import('@vueuse/core').reactiveComputed
const reactiveOmit: typeof import('@vueuse/core').reactiveOmit
const reactivePick: typeof import('@vueuse/core').reactivePick
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const refAutoReset: typeof import('@vueuse/core').refAutoReset
const refDebounced: typeof import('@vueuse/core').refDebounced
const refDefault: typeof import('@vueuse/core').refDefault
const refManualReset: typeof import('@vueuse/core').refManualReset
const refThrottled: typeof import('@vueuse/core').refThrottled
const refWithControl: typeof import('@vueuse/core').refWithControl
const resolveComponent: typeof import('vue').resolveComponent
const resolveRef: typeof import('@vueuse/core').resolveRef
const resolveUnref: typeof import('@vueuse/core').resolveUnref
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const syncRef: typeof import('@vueuse/core').syncRef
const syncRefs: typeof import('@vueuse/core').syncRefs
const templateRef: typeof import('@vueuse/core').templateRef
const throttledRef: typeof import('@vueuse/core').throttledRef
const throttledWatch: typeof import('@vueuse/core').throttledWatch
const toRaw: typeof import('vue').toRaw
const toReactive: typeof import('@vueuse/core').toReactive
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const tryOnBeforeMount: typeof import('@vueuse/core').tryOnBeforeMount
const tryOnBeforeUnmount: typeof import('@vueuse/core').tryOnBeforeUnmount
const tryOnMounted: typeof import('@vueuse/core').tryOnMounted
const tryOnScopeDispose: typeof import('@vueuse/core').tryOnScopeDispose
const tryOnUnmounted: typeof import('@vueuse/core').tryOnUnmounted
const unref: typeof import('vue').unref
const unrefElement: typeof import('@vueuse/core').unrefElement
const until: typeof import('@vueuse/core').until
const useActiveElement: typeof import('@vueuse/core').useActiveElement
const useAnimate: typeof import('@vueuse/core').useAnimate
const useArrayDifference: typeof import('@vueuse/core').useArrayDifference
const useArrayEvery: typeof import('@vueuse/core').useArrayEvery
const useArrayFilter: typeof import('@vueuse/core').useArrayFilter
const useArrayFind: typeof import('@vueuse/core').useArrayFind
const useArrayFindIndex: typeof import('@vueuse/core').useArrayFindIndex
const useArrayFindLast: typeof import('@vueuse/core').useArrayFindLast
const useArrayIncludes: typeof import('@vueuse/core').useArrayIncludes
const useArrayJoin: typeof import('@vueuse/core').useArrayJoin
const useArrayMap: typeof import('@vueuse/core').useArrayMap
const useArrayReduce: typeof import('@vueuse/core').useArrayReduce
const useArraySome: typeof import('@vueuse/core').useArraySome
const useArrayUnique: typeof import('@vueuse/core').useArrayUnique
const useAsyncQueue: typeof import('@vueuse/core').useAsyncQueue
const useAsyncState: typeof import('@vueuse/core').useAsyncState
const useAttrs: typeof import('vue').useAttrs
const useBase64: typeof import('@vueuse/core').useBase64
const useBattery: typeof import('@vueuse/core').useBattery
const useBluetooth: typeof import('@vueuse/core').useBluetooth
const useBreakpoints: typeof import('@vueuse/core').useBreakpoints
const useBroadcastChannel: typeof import('@vueuse/core').useBroadcastChannel
const useBrowserLocation: typeof import('@vueuse/core').useBrowserLocation
const useCached: typeof import('@vueuse/core').useCached
const useClipboard: typeof import('@vueuse/core').useClipboard
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
const useCloned: typeof import('@vueuse/core').useCloned
const useColorMode: typeof import('@vueuse/core').useColorMode
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
const useCountdown: typeof import('@vueuse/core').useCountdown
const useCounter: typeof import('@vueuse/core').useCounter
const useCssModule: typeof import('vue').useCssModule
const useCssVar: typeof import('@vueuse/core').useCssVar
const useCssVars: typeof import('vue').useCssVars
const useCurrentElement: typeof import('@vueuse/core').useCurrentElement
const useCycleList: typeof import('@vueuse/core').useCycleList
const useDark: typeof import('@vueuse/core').useDark
const useDateFormat: typeof import('@vueuse/core').useDateFormat
const useDebounce: typeof import('@vueuse/core').useDebounce
const useDebounceFn: typeof import('@vueuse/core').useDebounceFn
const useDebouncedRefHistory: typeof import('@vueuse/core').useDebouncedRefHistory
const useDeviceMotion: typeof import('@vueuse/core').useDeviceMotion
const useDeviceOrientation: typeof import('@vueuse/core').useDeviceOrientation
const useDevicePixelRatio: typeof import('@vueuse/core').useDevicePixelRatio
const useDevicesList: typeof import('@vueuse/core').useDevicesList
const useDialog: typeof import('naive-ui').useDialog
const useDisplayMedia: typeof import('@vueuse/core').useDisplayMedia
const useDocumentVisibility: typeof import('@vueuse/core').useDocumentVisibility
const useDraggable: typeof import('@vueuse/core').useDraggable
const useDropZone: typeof import('@vueuse/core').useDropZone
const useElementBounding: typeof import('@vueuse/core').useElementBounding
const useElementByPoint: typeof import('@vueuse/core').useElementByPoint
const useElementHover: typeof import('@vueuse/core').useElementHover
const useElementSize: typeof import('@vueuse/core').useElementSize
const useElementVisibility: typeof import('@vueuse/core').useElementVisibility
const useEventBus: typeof import('@vueuse/core').useEventBus
const useEventListener: typeof import('@vueuse/core').useEventListener
const useEventSource: typeof import('@vueuse/core').useEventSource
const useEyeDropper: typeof import('@vueuse/core').useEyeDropper
const useFavicon: typeof import('@vueuse/core').useFavicon
const useFetch: typeof import('@vueuse/core').useFetch
const useFileDialog: typeof import('@vueuse/core').useFileDialog
const useFileSystemAccess: typeof import('@vueuse/core').useFileSystemAccess
const useFocus: typeof import('@vueuse/core').useFocus
const useFocusWithin: typeof import('@vueuse/core').useFocusWithin
const useFps: typeof import('@vueuse/core').useFps
const useFullscreen: typeof import('@vueuse/core').useFullscreen
const useGamepad: typeof import('@vueuse/core').useGamepad
const useGeolocation: typeof import('@vueuse/core').useGeolocation
const useId: typeof import('vue').useId
const useIdle: typeof import('@vueuse/core').useIdle
const useImage: typeof import('@vueuse/core').useImage
const useInfiniteScroll: typeof import('@vueuse/core').useInfiniteScroll
const useIntersectionObserver: typeof import('@vueuse/core').useIntersectionObserver
const useInterval: typeof import('@vueuse/core').useInterval
const useIntervalFn: typeof import('@vueuse/core').useIntervalFn
const useKeyModifier: typeof import('@vueuse/core').useKeyModifier
const useLastChanged: typeof import('@vueuse/core').useLastChanged
const useLink: typeof import('vue-router').useLink
const useLoadingBar: typeof import('naive-ui').useLoadingBar
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
const useMagicKeys: typeof import('@vueuse/core').useMagicKeys
const useManualRefHistory: typeof import('@vueuse/core').useManualRefHistory
const useMediaControls: typeof import('@vueuse/core').useMediaControls
const useMediaQuery: typeof import('@vueuse/core').useMediaQuery
const useMemoize: typeof import('@vueuse/core').useMemoize
const useMemory: typeof import('@vueuse/core').useMemory
const useMessage: typeof import('naive-ui').useMessage
const useModel: typeof import('vue').useModel
const useMounted: typeof import('@vueuse/core').useMounted
const useMouse: typeof import('@vueuse/core').useMouse
const useMouseInElement: typeof import('@vueuse/core').useMouseInElement
const useMousePressed: typeof import('@vueuse/core').useMousePressed
const useMutationObserver: typeof import('@vueuse/core').useMutationObserver
const useNavigatorLanguage: typeof import('@vueuse/core').useNavigatorLanguage
const useNetwork: typeof import('@vueuse/core').useNetwork
const useNotification: typeof import('naive-ui').useNotification
const useNow: typeof import('@vueuse/core').useNow
const useObjectUrl: typeof import('@vueuse/core').useObjectUrl
const useOffsetPagination: typeof import('@vueuse/core').useOffsetPagination
const useOnline: typeof import('@vueuse/core').useOnline
const usePageLeave: typeof import('@vueuse/core').usePageLeave
const useParallax: typeof import('@vueuse/core').useParallax
const useParentElement: typeof import('@vueuse/core').useParentElement
const usePerformanceObserver: typeof import('@vueuse/core').usePerformanceObserver
const usePermission: typeof import('@vueuse/core').usePermission
const usePointer: typeof import('@vueuse/core').usePointer
const usePointerLock: typeof import('@vueuse/core').usePointerLock
const usePointerSwipe: typeof import('@vueuse/core').usePointerSwipe
const usePreferredColorScheme: typeof import('@vueuse/core').usePreferredColorScheme
const usePreferredContrast: typeof import('@vueuse/core').usePreferredContrast
const usePreferredDark: typeof import('@vueuse/core').usePreferredDark
const usePreferredLanguages: typeof import('@vueuse/core').usePreferredLanguages
const usePreferredReducedMotion: typeof import('@vueuse/core').usePreferredReducedMotion
const usePreferredReducedTransparency: typeof import('@vueuse/core').usePreferredReducedTransparency
const usePrevious: typeof import('@vueuse/core').usePrevious
const useRafFn: typeof import('@vueuse/core').useRafFn
const useRefHistory: typeof import('@vueuse/core').useRefHistory
const useResizeObserver: typeof import('@vueuse/core').useResizeObserver
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSSRWidth: typeof import('@vueuse/core').useSSRWidth
const useScreenOrientation: typeof import('@vueuse/core').useScreenOrientation
const useScreenSafeArea: typeof import('@vueuse/core').useScreenSafeArea
const useScriptTag: typeof import('@vueuse/core').useScriptTag
const useScroll: typeof import('@vueuse/core').useScroll
const useScrollLock: typeof import('@vueuse/core').useScrollLock
const useSessionStorage: typeof import('@vueuse/core').useSessionStorage
const useShare: typeof import('@vueuse/core').useShare
const useSlots: typeof import('vue').useSlots
const useSorted: typeof import('@vueuse/core').useSorted
const useSpeechRecognition: typeof import('@vueuse/core').useSpeechRecognition
const useSpeechSynthesis: typeof import('@vueuse/core').useSpeechSynthesis
const useStepper: typeof import('@vueuse/core').useStepper
const useStorage: typeof import('@vueuse/core').useStorage
const useStorageAsync: typeof import('@vueuse/core').useStorageAsync
const useStyleTag: typeof import('@vueuse/core').useStyleTag
const useSupported: typeof import('@vueuse/core').useSupported
const useSwipe: typeof import('@vueuse/core').useSwipe
const useTemplateRef: typeof import('vue').useTemplateRef
const useTemplateRefsList: typeof import('@vueuse/core').useTemplateRefsList
const useTextDirection: typeof import('@vueuse/core').useTextDirection
const useTextSelection: typeof import('@vueuse/core').useTextSelection
const useTextareaAutosize: typeof import('@vueuse/core').useTextareaAutosize
const useThrottle: typeof import('@vueuse/core').useThrottle
const useThrottleFn: typeof import('@vueuse/core').useThrottleFn
const useThrottledRefHistory: typeof import('@vueuse/core').useThrottledRefHistory
const useTimeAgo: typeof import('@vueuse/core').useTimeAgo
const useTimeAgoIntl: typeof import('@vueuse/core').useTimeAgoIntl
const useTimeout: typeof import('@vueuse/core').useTimeout
const useTimeoutFn: typeof import('@vueuse/core').useTimeoutFn
const useTimeoutPoll: typeof import('@vueuse/core').useTimeoutPoll
const useTimestamp: typeof import('@vueuse/core').useTimestamp
const useTitle: typeof import('@vueuse/core').useTitle
const useToNumber: typeof import('@vueuse/core').useToNumber
const useToString: typeof import('@vueuse/core').useToString
const useToggle: typeof import('@vueuse/core').useToggle
const useTransition: typeof import('@vueuse/core').useTransition
const useUrlSearchParams: typeof import('@vueuse/core').useUrlSearchParams
const useUserMedia: typeof import('@vueuse/core').useUserMedia
const useVModel: typeof import('@vueuse/core').useVModel
const useVModels: typeof import('@vueuse/core').useVModels
const useVibrate: typeof import('@vueuse/core').useVibrate
const useVirtualList: typeof import('@vueuse/core').useVirtualList
const useWakeLock: typeof import('@vueuse/core').useWakeLock
const useWebNotification: typeof import('@vueuse/core').useWebNotification
const useWebSocket: typeof import('@vueuse/core').useWebSocket
const useWebWorker: typeof import('@vueuse/core').useWebWorker
const useWebWorkerFn: typeof import('@vueuse/core').useWebWorkerFn
const useWindowFocus: typeof import('@vueuse/core').useWindowFocus
const useWindowScroll: typeof import('@vueuse/core').useWindowScroll
const useWindowSize: typeof import('@vueuse/core').useWindowSize
const watch: typeof import('vue').watch
const watchArray: typeof import('@vueuse/core').watchArray
const watchAtMost: typeof import('@vueuse/core').watchAtMost
const watchDebounced: typeof import('@vueuse/core').watchDebounced
const watchDeep: typeof import('@vueuse/core').watchDeep
const watchEffect: typeof import('vue').watchEffect
const watchIgnorable: typeof import('@vueuse/core').watchIgnorable
const watchImmediate: typeof import('@vueuse/core').watchImmediate
const watchOnce: typeof import('@vueuse/core').watchOnce
const watchPausable: typeof import('@vueuse/core').watchPausable
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
const watchThrottled: typeof import('@vueuse/core').watchThrottled
const watchTriggerable: typeof import('@vueuse/core').watchTriggerable
const watchWithFilter: typeof import('@vueuse/core').watchWithFilter
const whenever: typeof import('@vueuse/core').whenever
}
// for type re-export
declare global {

14
components.d.ts vendored
View File

@@ -1,8 +1,11 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
@@ -61,8 +64,11 @@ declare module 'vue' {
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDropdown: typeof import('naive-ui')['NDropdown']
NDynamicTags: typeof import('naive-ui')['NDynamicTags']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NFloatButton: typeof import('naive-ui')['NFloatButton']
NFloatButtonGroup: typeof import('naive-ui')['NFloatButtonGroup']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
@@ -70,6 +76,7 @@ declare module 'vue' {
NGlobalStyle: typeof import('naive-ui')['NGlobalStyle']
NGrid: typeof import('naive-ui')['NGrid']
NH1: typeof import('naive-ui')['NH1']
NH2: typeof import('naive-ui')['NH2']
NH3: typeof import('naive-ui')['NH3']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
@@ -93,14 +100,18 @@ declare module 'vue' {
NP: typeof import('naive-ui')['NP']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NQrCode: typeof import('naive-ui')['NQrCode']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NResult: typeof import('naive-ui')['NResult']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSkeleton: typeof import('naive-ui')['NSkeleton']
NSlider: typeof import('naive-ui')['NSlider']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
@@ -135,6 +146,7 @@ declare module 'vue' {
SongList: typeof import('./src/components/List/SongList.vue')['default']
SongListCard: typeof import('./src/components/Card/SongListCard.vue')['default']
SongListMenu: typeof import('./src/components/Menu/SongListMenu.vue')['default']
SongUnlockManager: typeof import('./src/components/Modal/SongUnlockManager.vue')['default']
SvgIcon: typeof import('./src/components/Global/SvgIcon.vue')['default']
TextContainer: typeof import('./src/components/Global/TextContainer.vue')['default']
UpdateApp: typeof import('./src/components/Modal/UpdateApp.vue')['default']

View File

@@ -152,7 +152,7 @@ const config: Configuration = {
// 维护者信息
maintainer: "imsyy.top",
// 应用程序类别
category: "Audio;Music",
category: "Audio;Music;AudioVideo;",
},
// AppImage 特定配置
appImage: {

View File

@@ -2,11 +2,13 @@ import { app, BrowserWindow } from "electron";
import { electronApp } from "@electron-toolkit/utils";
import { release, type } from "os";
import { isMac } from "./utils/config";
import { initSingleLock } from "./utils/single-lock";
import { unregisterShortcuts } from "./shortcut";
import { initTray, MainTray } from "./tray";
import { processLog } from "./logger";
import { existsSync, mkdirSync } from "fs";
import { join } from "path";
import initAppServer from "../server";
import { initSingleLock } from "./utils/single-lock";
import loadWindow from "./windows/load-window";
import mainWindow from "./windows/main-window";
import initIpc from "./ipc";
@@ -14,6 +16,17 @@ import initIpc from "./ipc";
// 屏蔽报错
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
// 便携模式下设置用户数据路径
if (process.env.PORTABLE_EXECUTABLE_DIR) {
processLog.info(
"🔍 Portable mode detected, setting userData path to:",
join(process.env.PORTABLE_EXECUTABLE_DIR, "UserData"),
);
const userDataPath = join(process.env.PORTABLE_EXECUTABLE_DIR, "UserData");
if (!existsSync(userDataPath)) mkdirSync(userDataPath, { recursive: true });
app.setPath("userData", userDataPath);
}
// 主进程
class MainProcess {
// 窗口

View File

@@ -5,13 +5,23 @@ import { parseFile } from "music-metadata";
import { getFileID, getFileMD5, metaDataLyricsArrayToLrc } from "../utils/helper";
import { File, Picture, Id3v2Settings } from "node-taglib-sharp";
import { ipcLog } from "../logger";
import FastGlob from "fast-glob";
import { download } from "electron-dl";
import { Options as GlobOptions } from "fast-glob/out/settings";
import FastGlob from "fast-glob";
/**
* 文件相关 IPC
*/
const initFileIpc = (): void => {
/**
* 获取全局搜索配置
* @param cwd 当前工作目录
*/
const globOpt = (cwd?: string): GlobOptions => ({
cwd,
caseSensitiveMatch: false,
});
// 默认文件夹
ipcMain.handle(
"get-default-dir",
@@ -27,7 +37,7 @@ const initFileIpc = (): void => {
const filePath = resolve(dirPath).replace(/\\/g, "/");
console.info(`📂 Fetching music files from: ${filePath}`);
// 查找指定目录下的所有音乐文件
const musicFiles = await FastGlob("**/*.{mp3,wav,flac,aac,webm}", { cwd: filePath });
const musicFiles = await FastGlob("**/*.{mp3,wav,flac,aac,webm}", globOpt(filePath));
// 解析元信息
const metadataPromises = musicFiles.map(async (file) => {
const filePath = join(dirPath, file);
@@ -35,15 +45,6 @@ const initFileIpc = (): void => {
const { common, format } = await parseFile(filePath);
// 获取文件大小
const { size } = await stat(filePath);
// 判断音质等级
let quality: string;
if ((format.sampleRate || 0) >= 96000 || (format.bitsPerSample || 0) > 16) {
quality = "Hi-Res";
} else if ((format.sampleRate || 0) >= 44100) {
quality = "HQ";
} else {
quality = "SQ";
}
return {
id: getFileID(filePath),
name: common.title || basename(filePath),
@@ -53,7 +54,7 @@ const initFileIpc = (): void => {
duration: (format?.duration ?? 0) * 1000,
size: (size / (1024 * 1024)).toFixed(2),
path: filePath,
quality,
quality: format.bitrate ?? 0,
};
});
const metadataArray = await Promise.all(metadataPromises);
@@ -136,23 +137,25 @@ const initFileIpc = (): void => {
}> => {
try {
const filePath = resolve(path).replace(/\\/g, "/");
const { common } = await parseFile(filePath);
// 尝试获取同名的歌词文件
const filePathWithoutExt = filePath.replace(/\.[^.]+$/, "");
for (const ext of ["ttml", "lrc"] as const) {
const lyricPath = `${filePathWithoutExt}.${ext}`;
ipcLog.info("lyricPath", lyricPath);
try {
await access(lyricPath);
const lyric = await readFile(lyricPath, "utf-8");
if (lyric && lyric != "") return { lyric, format: ext };
} catch {
/* empty */
const matches = await FastGlob(lyricPath, globOpt());
ipcLog.info("lyric matches", matches);
if (matches.length > 0) {
try {
const lyric = await readFile(matches[0], "utf-8");
if (lyric && lyric !== "") return { lyric, format: ext };
} catch {
/* empty */
}
}
}
// 尝试获取元数据
const { common } = await parseFile(filePath);
const lyric = common?.lyrics?.[0]?.syncText;
if (lyric && lyric.length > 0) {
return { lyric: metaDataLyricsArrayToLrc(lyric), format: "lrc" };
@@ -199,20 +202,52 @@ const initFileIpc = (): void => {
// 读取本地歌词
ipcMain.handle(
"read-local-lyric",
async (_, lyricDir: string, id: number, ext: string): Promise<string> => {
const pattern = `**/{,*.}${id}.${ext}`;
async (_, lyricDirs: string[], id: number): Promise<{ lrc: string; ttml: string }> => {
const result = { lrc: "", ttml: "" };
try {
const files = await FastGlob(pattern, { cwd: lyricDir });
if (files.length > 0) {
const firstMatch = join(lyricDir, files[0]);
await access(firstMatch);
const lyric = await readFile(firstMatch, "utf-8");
if (lyric) return lyric;
// 定义需要查找的模式
// 此处的 `{,*.}` 表示这里可以取 `` (empty) 也可以取 `*.`
// 将歌词文件命名为 `歌曲ID.后缀名` 或者 `任意前缀.歌曲ID.后缀名` 均可
const patterns = {
ttml: `**/{,*.}${id}.ttml`,
lrc: `**/{,*.}${id}.lrc`,
};
// 遍历每一个目录
for (const dir of lyricDirs) {
try {
// 查找 ttml
if (!result.ttml) {
const ttmlFiles = await FastGlob(patterns.ttml, globOpt(dir));
if (ttmlFiles.length > 0) {
const filePath = join(dir, ttmlFiles[0]);
await access(filePath);
result.ttml = await readFile(filePath, "utf-8");
}
}
// 查找 lrc
if (!result.lrc) {
const lrcFiles = await FastGlob(patterns.lrc, globOpt(dir));
if (lrcFiles.length > 0) {
const filePath = join(dir, lrcFiles[0]);
await access(filePath);
result.lrc = await readFile(filePath, "utf-8");
}
}
// 如果两种文件都找到了就提前结束搜索
if (result.ttml && result.lrc) break;
} catch {
// 某个路径异常,跳过
}
}
} catch {
/* empty */
/* 忽略错误 */
}
return "";
return result;
},
);

View File

@@ -82,7 +82,7 @@ const initLyricIpc = (): void => {
// 音乐名称更改
ipcMain.on("play-song-change", (_, title) => {
if (!title || !isWinAlive(lyricWin)) return;
if (!isWinAlive(lyricWin)) return;
lyricWin.webContents.send("update-desktop-lyric-data", { playName: title });
});

View File

@@ -1,6 +1,7 @@
import { ipcMain } from "electron";
import { getMainTray } from "../tray";
import lyricWindow from "../windows/lyric-window";
import { appName } from "../utils/config";
/**
* 托盘 IPC
@@ -18,7 +19,7 @@ const initTrayIpc = (): void => {
// 音乐名称更改
ipcMain.on("play-song-change", (_, title) => {
if (!title) return;
if (!title) title = appName;
// 更改标题
tray?.setTitle(title);
tray?.setPlayName(title);

View File

@@ -0,0 +1,107 @@
import { SongUrlResult } from "./unblock";
import { serverLog } from "../../main/logger";
import axios from "axios";
import { randomBytes } from "crypto";
/**
* 搜索歌曲获取 ID
* @param keyword 搜索关键词
* @returns 歌曲 ID 或 null
*/
const search = async (keyword: string): Promise<string | null> => {
try {
const searchUrl = `https://www.gequbao.com/s/${encodeURIComponent(keyword)}`;
const { data } = await axios.get(searchUrl);
// 匹配第一个歌曲链接 /music/12345
// <a href="/music/17165" target="_blank" class="music-link d-block">
const match = data.match(
/<a href="\/music\/(\d+)" target="_blank" class="music-link d-block">/,
);
if (match && match[1]) {
return match[1];
}
return null;
} catch (error) {
serverLog.error("❌ Get GequbaoSongId Error:", error);
return null;
}
};
/**
* 获取播放 ID
* @param id 歌曲 ID
* @returns 播放 ID 或 null
*/
const getPlayId = async (id: string): Promise<string | null> => {
try {
const url = `https://www.gequbao.com/music/${id}`;
const { data } = await axios.get(url);
// 匹配 window.appData 中的 play_id
// "play_id":"EFwMVSQDBgsBQV5WBCUDAVkCSQ9WX3kFXV9XEl0KBSEaVldTR19NVndQVlhXRl5cUA=="
const match = data.match(/"play_id":"(.*?)"/);
if (match && match[1]) {
return match[1];
}
return null;
} catch (error) {
serverLog.error("❌ Get GequbaoPlayId Error:", error);
return null;
}
};
/**
* 获取歌曲 URL
* @param keyword 搜索关键词
* @returns 包含歌曲 URL 的结果对象
*/
const getGequbaoSongUrl = async (keyword: string): Promise<SongUrlResult> => {
try {
if (!keyword) return { code: 404, url: null };
// 1. 获取 ID
const id = await search(keyword);
if (!id) return { code: 404, url: null };
// 2. 获取 play_id
const playId = await getPlayId(id);
if (!playId) return { code: 404, url: null };
// 3. 获取播放链接
const url = "https://www.gequbao.com/api/play-url";
const headers = {
accept: "application/json, text/javascript, */*; q=0.01",
"accept-language": "zh-CN,zh;q=0.9",
"cache-control": "no-cache",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
pragma: "no-cache",
priority: "u=1, i",
"sec-ch-ua": '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"x-requested-with": "XMLHttpRequest",
cookie: `server_name_session=${randomBytes(16).toString("hex")}`,
Referer: `https://www.gequbao.com/music/${id}`,
};
const body = `id=${encodeURIComponent(playId)}`;
const { data } = await axios.post(url, body, { headers });
if (data.code === 1 && data.data && data.data.url) {
serverLog.log("🔗 GequbaoSong URL:", data.data.url);
return { code: 200, url: data.data.url };
}
return { code: 404, url: null };
} catch (error) {
serverLog.error("❌ Get GequbaoSong URL Error:", error);
return { code: 404, url: null };
}
};
export default getGequbaoSongUrl;

View File

@@ -1,9 +1,10 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { SongUrlResult } from "./unblock";
import { serverLog } from "../../main/logger";
import getKuwoSongUrl from "./kuwo";
import axios from "axios";
import getKuwoSongUrl from "./kuwo";
import getBodianSongUrl from "./bodian";
import getGequbaoSongUrl from "./gequbao";
/**
* 直接获取 网易云云盘 链接
@@ -74,6 +75,18 @@ export const initUnblockAPI = async (fastify: FastifyInstance) => {
return reply.send(result);
},
);
// gequbao
fastify.get(
"/unblock/gequbao",
async (
req: FastifyRequest<{ Querystring: { [key: string]: string } }>,
reply: FastifyReply,
) => {
const { keyword } = req.query;
const result = await getGequbaoSongUrl(keyword);
return reply.send(result);
},
);
serverLog.info("🌐 Register UnblockAPI successfully");
};

View File

@@ -1,7 +1,7 @@
{
"name": "splayer",
"productName": "SPlayer",
"version": "3.0.0-beta.5",
"version": "3.0.0-beta.6",
"description": "A minimalist music player",
"main": "./out/main/index.js",
"author": "imsyy",
@@ -40,7 +40,7 @@
"@electron-toolkit/utils": "^4.0.0",
"@imsyy/color-utils": "^1.0.2",
"@material/material-color-utilities": "^0.3.0",
"@neteasecloudmusicapienhanced/api": "^4.29.16",
"@neteasecloudmusicapienhanced/api": "^4.29.17",
"@pixi/app": "^7.4.3",
"@pixi/core": "^7.4.3",
"@pixi/display": "^7.4.3",
@@ -49,10 +49,11 @@
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@vueuse/core": "^13.9.0",
"@vueuse/integrations": "^14.0.0",
"axios": "^1.13.2",
"axios-retry": "^4.5.0",
"change-case": "^5.4.4",
"dayjs": "^1.11.18",
"dayjs": "^1.11.19",
"electron-dl": "^4.0.0",
"electron-store": "^11.0.2",
"electron-updater": "^6.6.2",
@@ -66,12 +67,13 @@
"jss-preset-default": "^10.10.0",
"localforage": "^1.10.0",
"lodash-es": "^4.17.21",
"marked": "^16.4.0",
"marked": "^16.4.2",
"md5": "^2.3.0",
"music-metadata": "^11.9.0",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"music-metadata": "^11.10.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"plyr": "^3.8.3",
"sortablejs": "^1",
"vue-virt-list": "^1.6.1"
},
"devDependencies": {
@@ -80,15 +82,15 @@
"@electron-toolkit/utils": "^4",
"@fastify/cookie": "^11.0.2",
"@fastify/http-proxy": "^11.3.0",
"@fastify/multipart": "^9.2.1",
"@fastify/static": "^8.2.0",
"@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.3.0",
"@types/file-saver": "^2.0.7",
"@types/howler": "^2.2.12",
"@types/js-cookie": "^3.0.6",
"@types/md5": "^2.3.5",
"@types/node": "^24.7.2",
"@typescript-eslint/eslint-plugin": "^8.46.0",
"@typescript-eslint/parser": "^8.46.0",
"@types/md5": "^2.3.6",
"@types/node": "^24.10.1",
"@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.46.4",
"@vitejs/plugin-vue": "^6.0.1",
"ajv": "^8.17.1",
"crypto-js": "^4.2.0",
@@ -96,24 +98,24 @@
"electron-builder": "^26.0.12",
"electron-log": "^5.4.3",
"electron-vite": "^4.0.1",
"eslint": "^9.37.0",
"eslint-plugin-vue": "^10.5.0",
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.5.1",
"fast-glob": "^3.3.3",
"fastify": "^5.6.1",
"fastify": "^5.6.2",
"naive-ui": "^2.43.1",
"node-taglib-sharp": "^6.0.1",
"prettier": "^3.6.2",
"sass": "^1.93.2",
"terser": "^5.44.0",
"sass": "^1.94.0",
"terser": "^5.44.1",
"typescript": "^5.9.3",
"unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^29.1.0",
"unplugin-vue-components": "^29.2.0",
"vite": "^7.2.2",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^8.0.3",
"vite-plugin-wasm": "^3.5.0",
"vue": "^3.5.24",
"vue-router": "^4.5.1",
"vue-router": "^4.6.3",
"vue-tsc": "^3.1.3"
},
"pnpm": {
@@ -122,7 +124,9 @@
"electron-builder-squirrel-windows": "26.0.12"
},
"onlyBuiltDependencies": [
"@applemusic-like-lyrics/core",
"@applemusic-like-lyrics/lyric",
"@applemusic-like-lyrics/vue",
"@parcel/watcher",
"core-js",
"electron",

1595
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
screenshots/welcome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1,5 +1,6 @@
import { isElectron } from "@/utils/env";
import { songLevelData } from "@/utils/meta";
import { SongUnlockServer } from "@/utils/songManager";
import request from "@/utils/request";
// 获取歌曲详情
@@ -47,16 +48,12 @@ export const songUrl = (
};
// 获取解锁歌曲 URL
export const unlockSongUrl = (
id: number,
keyword: string,
server: "netease" | "kuwo" | "bodian",
) => {
const params = server === "netease" ? { id } : { keyword };
export const unlockSongUrl = (id: number, keyword: string, server: SongUnlockServer) => {
const params = server === SongUnlockServer.NETEASE ? { id } : { keyword };
return request({
baseURL: "/api/unblock",
url: `/${server}`,
params,
params: { ...params, noCookie: true },
});
};

1
src/assets/icons/QQ.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Remix Icon by Remix Design - https://github.com/Remix-Design/RemixIcon/blob/master/License --><path fill="currentColor" d="M19.914 14.529a32 32 0 0 0-.676-1.886l-.91-2.246c.002-.026.013-.468.013-.696C18.34 5.86 16.508 2 12 2S5.66 5.86 5.66 9.7c0 .229.011.671.012.697l-.91 2.246a32 32 0 0 0-.675 1.886c-.86 2.737-.581 3.87-.369 3.895c.455.054 1.771-2.06 1.771-2.06c0 1.224.637 2.822 2.016 3.976c-.515.157-1.147.399-1.554.695c-.365.267-.319.54-.253.65c.289.481 4.955.307 6.303.157c1.347.15 6.014.324 6.302-.158c.066-.11.112-.382-.253-.649c-.407-.296-1.039-.538-1.555-.695c1.379-1.154 2.016-2.752 2.016-3.977c0 0 1.316 2.115 1.771 2.06c.212-.025.49-1.157-.37-3.894"/></svg>

After

Width:  |  Height:  |  Size: 768 B

View File

@@ -43,29 +43,33 @@
</n-ellipsis>
<!-- 音质 -->
<n-tag
v-if="song?.path && song?.quality"
:bordered="false"
:type="song.quality === 'Hi-Res' ? 'warning' : 'info'"
v-if="song?.quality && settingStore.showSongQuality"
:type="qualityColor"
class="quality"
round
>
{{ song.quality }}
</n-tag>
<!-- 原唱翻唱 -->
<template>
<n-tag v-if="song.originCoverType === 1" :bordered="false" type="primary" round>
</n-tag>
<n-tag v-if="song.originCoverType === 2" :bordered="false" type="info" round>
翻唱
</n-tag>
</template>
<!-- 特权 -->
<n-tag v-if="song.originCoverType === 1" :bordered="false" type="primary" round>
</n-tag>
<n-tag v-if="song.originCoverType === 2" :bordered="false" type="info" round>
翻唱
</n-tag>
<n-tag v-if="song.free === 1" :bordered="false" type="error" round> VIP </n-tag>
<n-tag v-if="song.free === 4" :bordered="false" type="error" round> EP </n-tag>
<!-- 云盘 -->
<n-tag v-if="song?.pc" :bordered="false" class="cloud" type="info" round>
<template #icon>
<SvgIcon name="Cloud" />
</template>
</n-tag>
<template v-if="settingStore.showSongPrivilegeTag">
<n-tag v-if="song.free === 1" :bordered="false" type="error" round> VIP </n-tag>
<n-tag v-if="song.free === 4" :bordered="false" type="error" round> EP </n-tag>
<!-- 云盘 -->
<n-tag v-if="song?.pc" :bordered="false" class="cloud" type="info" round>
<template #icon>
<SvgIcon name="Cloud" />
</template>
</n-tag>
</template>
<!-- MV -->
<n-tag
v-if="song?.mv"
@@ -151,16 +155,16 @@
</template>
<script setup lang="ts">
import type { SongType } from "@/types/main";
import { useStatusStore, useMusicStore, useDataStore } from "@/stores";
import { QualityType, type SongType } from "@/types/main";
import { useStatusStore, useMusicStore, useDataStore, useSettingStore } from "@/stores";
import { formatNumber } from "@/utils/helper";
import { openJumpArtist } from "@/utils/modal";
import { toLikeSong } from "@/utils/auth";
import { isObject } from "lodash-es";
import { formatTimestamp, msToTime } from "@/utils/time";
import player from "@/utils/player";
import blob from "@/utils/blob";
import { usePlayer } from "@/utils/player";
import { isElectron } from "@/utils/env";
import blob from "@/utils/blob";
const props = defineProps<{
// 歌曲
@@ -174,13 +178,23 @@ const props = defineProps<{
}>();
const router = useRouter();
const player = usePlayer();
const dataStore = useDataStore();
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 歌曲数据
const song = toRef(props, "song");
// 音质颜色
const qualityColor = computed(() => {
if (song.value.quality === QualityType.HiRes) return "warning";
if (song.value.quality === QualityType.SQ) return "warning";
if (song.value.quality === QualityType.HQ) return "info";
return "primary";
});
// 加载本地歌曲封面
const localCover = async (show: boolean) => {
if (!isElectron || !show || !song.value.path) return;

View File

@@ -217,6 +217,9 @@ const changeGlobalTheme = () => {
railColor: toRGBA(primaryRGB, 0.2),
railColorHover: toRGBA(primaryRGB, 0.3),
},
Popover: {
color: `rgb(${surfaceContainerRGB})`,
},
};
}
} catch (error) {

View File

@@ -28,14 +28,16 @@ const isTextOverflowing = ref(false);
// 容器宽度
const { width: textContainerWidth } = useElementSize(textContainerRef);
// 文本宽度
const { width: textWidth } = useElementSize(textRef);
// 检查文本是否超出宽度
const checkTextWidth = () => {
if (textRef.value && textContainerRef.value) {
const textWidth = textRef.value.offsetWidth;
const containerWidth = textContainerWidth.value;
const currentTextWidth = textRef.value.offsetWidth;
const currentContainerWidth = textContainerWidth.value;
// 判断阈值
isTextOverflowing.value = textWidth > containerWidth + 2;
isTextOverflowing.value = currentTextWidth > currentContainerWidth + 2;
}
// 更新状态
updateScroll();
@@ -56,6 +58,8 @@ let scrollTimeoutId: ReturnType<typeof setTimeout> | null = null;
// 开始滚动
const startScrolling = () => {
// 先停止之前的滚动
stopScrolling();
if (!textRef.value || !textContainerRef.value || !scrollWrapperRef.value || !textCloneRef.value)
return;
// 设置滚动速度( 单位:像素/帧
@@ -97,7 +101,7 @@ const stopScrolling = () => {
};
watch(
() => [props.text, textContainerWidth.value, textCloneRef.value],
() => [props.text, textContainerWidth.value, textWidth.value, textCloneRef.value],
() => {
nextTick(checkTextWidth);
},

View File

@@ -34,9 +34,10 @@ import { openCreatePlaylist } from "@/utils/modal";
import { debounce } from "lodash-es";
import { isLogin } from "@/utils/auth";
import { isElectron } from "@/utils/env";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const router = useRouter();
const player = usePlayer();
const dataStore = useDataStore();
const musicStore = useMusicStore();
const statusStore = useStatusStore();

View File

@@ -91,9 +91,10 @@
<script setup lang="ts">
import type { DropdownOption } from "naive-ui";
import { useSettingStore } from "@/stores";
import { renderIcon } from "@/utils/helper";
import { openLink, renderIcon } from "@/utils/helper";
import { openSetting } from "@/utils/modal";
import { isDev, isElectron } from "@/utils/env";
import packageJson from "@/../package.json";
const router = useRouter();
const settingStore = useSettingStore();
@@ -155,7 +156,30 @@ const setOptions = computed<DropdownOption[]>(() => [
),
},
{
key: "header-divider",
key: "divider-1",
type: "divider",
},
{
// 交流群
key: "qq",
label: "加入交流群",
props: {
onClick: () =>
openLink(
"https://qm.qq.com/cgi-bin/qm/qr?k=2-cVSf1bE0AvAehCib00qFEFdUvPaJ_k&jump_from=webapi&authKey=1NEhib9+GsmsXVo2rCc0IbRaVHeeRXJJ0gbsyKDcIwDdAzYySOubkFCvkV32+7Cw",
),
},
icon: renderIcon("QQ"),
},
{
// 交流群
key: "github",
label: "开源仓库",
props: { onClick: () => openLink(packageJson.github) },
icon: renderIcon("Github"),
},
{
key: "divider-2",
type: "divider",
},
{

View File

@@ -218,6 +218,9 @@ const likeComment = debounce(async (data: CommentType) => {
color: var(--primary-hex);
}
}
.text {
white-space: pre-wrap;
}
}
.reply {
width: 100%;
@@ -226,6 +229,9 @@ const likeComment = debounce(async (data: CommentType) => {
font-size: 13px;
margin-top: 6px;
background-color: rgba(var(--primary), 0.12);
.text {
white-space: pre-wrap;
}
}
.meta {
padding-top: 12px;

View File

@@ -121,8 +121,8 @@ import { formatSongsList } from "@/utils/format";
import { songDetail } from "@/api/song";
import { playlistAllSongs } from "@/api/playlist";
import { radioAllProgram } from "@/api/radio";
import { usePlayer } from "@/utils/player";
import CoverMenu from "@/components/Menu/CoverMenu.vue";
import player from "@/utils/player";
import { formatTimestamp } from "@/utils/time";
interface Props {
@@ -145,6 +145,7 @@ const emit = defineEmits<{
}>();
const router = useRouter();
const player = usePlayer();
const musicStore = useMusicStore();
const statusStore = useStatusStore();

View File

@@ -123,14 +123,14 @@
<script setup lang="ts">
import type { DropdownOption } from "naive-ui";
import type { SongType, SortType } from "@/types/main";
import { SongType, SortType } from "@/types/main";
import { useMusicStore, useStatusStore } from "@/stores";
import { VirtList } from "vue-virt-list";
import { entries, isEmpty } from "lodash-es";
import { sortOptions } from "@/utils/meta";
import { renderIcon } from "@/utils/helper";
import { usePlayer } from "@/utils/player";
import SongListMenu from "@/components/Menu/SongListMenu.vue";
import player from "@/utils/player";
const props = withDefaults(
defineProps<{
@@ -177,6 +177,7 @@ const emit = defineEmits<{
removeSong: [id: number[]];
}>();
const player = usePlayer();
const musicStore = useMusicStore();
const statusStore = useStatusStore();
@@ -234,10 +235,13 @@ const listData = computed<SongType[]>(() => {
const listKey = computed(() => {
// 每日推荐
if (props.isDailyRecommend) {
return musicStore.dailySongsData.timestamp || 0;
return `daily-${musicStore.dailySongsData.timestamp || 0}`;
}
// 其他列表长度(检测增删操作)
return listData.value?.length || 0;
// 使用 playListId 作为主要 key
if (props.playListId) {
return `playlist-${props.playListId}`;
}
return `type-${props.type}`;
});
// 列表是否具有播放歌曲
@@ -261,7 +265,9 @@ const sortMenuOptions = computed<DropdownOption[]>(() =>
// 列表滚动
const onScroll = (e: Event) => {
emit("scroll", e);
scrollTop.value = (e.target as HTMLElement).scrollTop;
const top = (e.target as HTMLElement).scrollTop;
scrollTop.value = top;
offset.value = top;
};
// 列表触底

View File

@@ -31,11 +31,12 @@ import { deleteSongs, isLogin } from "@/utils/auth";
import { songUrl } from "@/api/song";
import { dailyRecommendDislike } from "@/api/rec";
import { formatSongsList } from "@/utils/format";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const emit = defineEmits<{ removeSong: [index: number[]] }>();
const router = useRouter();
const player = usePlayer();
const dataStore = useDataStore();
const localStore = useLocalStore();
const statusStore = useStatusStore();

View File

@@ -66,8 +66,9 @@
<script setup lang="ts">
import { useStatusStore } from "@/stores";
import { convertSecondsToTime } from "@/utils/time";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const player = usePlayer();
const statusStore = useStatusStore();
// 自定义时长

View File

@@ -32,7 +32,8 @@
<script setup lang="ts">
import { useStatusStore } from "@/stores";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const player = usePlayer();
const statusStore = useStatusStore();
</script>

View File

@@ -39,8 +39,9 @@
<script setup lang="ts">
import { isElectron } from "@/utils/env";
import { useStatusStore } from "@/stores";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const player = usePlayer();
const statusStore = useStatusStore();
type PresetKey = keyof typeof presetList;

View File

@@ -0,0 +1,62 @@
<template>
<div class="song-unlock-manager">
<n-alert title="免责声明" type="info">
本功能仅作为测试使用资源来自网络若侵犯到您的权益请及时联系我们删除
</n-alert>
<div ref="sortableRef" class="sortable-list">
<n-card
v-for="item in settingStore.songUnlockServer"
:key="item.key"
:content-style="{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '16px',
}"
class="sortable-item"
>
<SvgIcon :depth="3" name="Menu" />
<n-text class="name">{{ item.key }}</n-text>
<n-switch v-model:value="item.enabled" :round="false" />
</n-card>
</div>
</div>
</template>
<script setup lang="ts">
import { useSettingStore } from "@/stores";
import { useSortable } from "@vueuse/integrations/useSortable";
const settingStore = useSettingStore();
const sortableRef = ref<HTMLElement | null>(null);
// 拖拽
useSortable(sortableRef, settingStore.songUnlockServer, {
animation: 150,
handle: ".n-icon",
});
</script>
<style scoped lang="scss">
.sortable-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 12px;
.sortable-item {
border-radius: 8px;
.n-icon {
font-size: 16px;
cursor: move;
}
.name {
font-size: 16px;
line-height: normal;
}
.n-switch {
margin-left: auto;
}
}
}
</style>

View File

@@ -36,8 +36,8 @@ const props = defineProps<{
// 是否显示
const isShow = computed(() => {
if (!settingStore.countDownShow) return false;
// 计算实时时间 - 0.5是否小于开始 + 持续时间,小于则显示,否则不显示
return props.seek + 0.5 < props.start + props.duration;
// 计算实时时间 - 0.5s 是否小于开始 + 持续时间,小于则显示,否则不显示
return props.seek + 500 < props.start + props.duration;
});
// 计算每个点的透明度

View File

@@ -30,21 +30,14 @@
:class="[
'player-content',
{
'no-lrc': noLrc,
pure: statusStore.pureLyricMode && musicStore.isHasLrc,
'no-lrc': !musicStore.isHasLrc,
},
]"
@mousemove="playerMove"
>
<Transition name="zoom">
<div
v-if="
!(statusStore.pureLyricMode && musicStore.isHasLrc) ||
musicStore.playSong.type === 'radio'
"
:key="musicStore.playSong.id"
class="content-left"
>
<div v-if="!pureLyricMode" :key="musicStore.playSong.id" class="content-left">
<!-- 封面 -->
<PlayerCover />
<!-- 数据 -->
@@ -58,6 +51,7 @@
v-if="statusStore.pureLyricMode && musicStore.isHasLrc"
:center="statusStore.pureLyricMode"
:theme="statusStore.mainColor"
:light="pureLyricMode"
/>
<!-- 歌词 -->
<MainAMLyric v-if="settingStore.useAMLyrics" />
@@ -85,8 +79,9 @@
import { useStatusStore, useMusicStore, useSettingStore } from "@/stores";
import { isElectron } from "@/utils/env";
import { throttle } from "lodash-es";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const player = usePlayer();
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
@@ -96,6 +91,20 @@ const isShowComment = computed<boolean>(
() => !musicStore.playSong.path && statusStore.showPlayerComment,
);
/** 没有歌词 */
const noLrc = computed<boolean>(() => {
const noNormalLrc = !musicStore.isHasLrc;
const noYrcAvailable = !musicStore.isHasYrc || !settingStore.showYrc;
// const notLoading = !statusStore.lyricLoading;
return noNormalLrc && noYrcAvailable;
});
/** 是否处于纯净模式 */
const pureLyricMode = computed<boolean>(
() => (statusStore.pureLyricMode && musicStore.isHasLrc) || musicStore.playSong.type === "radio",
);
// 主内容 key
const playerContentKey = computed(() => `${statusStore.pureLyricMode}`);
@@ -114,7 +123,8 @@ const instantLyrics = computed(() => {
const content = isYrc
? musicStore.songLyric.yrcData[statusStore.lyricIndex]
: musicStore.songLyric.lrcData[statusStore.lyricIndex];
return { content: content?.content, tran: settingStore.showTran && content?.tran };
const contentStr = content?.words?.map((v) => v.word).join("") || "";
return { content: contentStr, tran: settingStore.showTran && content?.translatedLyric };
});
// 隐藏播放元素

View File

@@ -1,12 +1,12 @@
<template>
<n-flex class="menu" justify="center" vertical>
<div class="menu-icon" @click="changeOffset(-0.5)">
<div class="menu-icon" @click="changeOffset(-500)">
<SvgIcon name="Replay5" />
</div>
<span class="time" @click="resetOffset()">
{{ currentTimeOffsetValue }}
</span>
<div class="menu-icon" @click="changeOffset(0.5)">
<div class="menu-icon" @click="changeOffset(500)">
<SvgIcon name="Forward5" />
</div>
<div class="divider" />
@@ -29,16 +29,18 @@ const statusStore = useStatusStore();
const currentSongId = computed(() => musicStore.playSong?.id as number | undefined);
/**
* 当前进度偏移值
* 当前进度偏移值显示为秒保留1位小数
*/
const currentTimeOffsetValue = computed(() => {
const currentTimeOffset = statusStore.getSongOffset(currentSongId.value);
return currentTimeOffset > 0 ? `+${currentTimeOffset}` : currentTimeOffset;
// 将毫秒转换为秒显示保留1位小数
const offsetSeconds = (currentTimeOffset / 1000).toFixed(1);
return currentTimeOffset > 0 ? `+${offsetSeconds}` : offsetSeconds;
});
/**
* 改变进度偏移
* @param delta 偏移量
* @param delta 偏移量(单位:毫秒)
*/
const changeOffset = (delta: number) => {
statusStore.incSongOffset(currentSongId.value, delta);

View File

@@ -34,13 +34,13 @@
<script setup lang="ts">
import { LyricPlayer } from "@applemusic-like-lyrics/vue";
import { LyricLine } from "@applemusic-like-lyrics/core";
import { type LyricLine } from "@applemusic-like-lyrics/lyric";
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
import { msToS } from "@/utils/time";
import { getLyricLanguage } from "@/utils/lyric";
import player from "@/utils/player";
import { getLyricLanguage } from "@/utils/format";
import { usePlayer } from "@/utils/player";
import LyricMenu from "./LyricMenu.vue";
const player = usePlayer();
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
@@ -48,16 +48,13 @@ const settingStore = useSettingStore();
const lyricPlayerRef = ref<any | null>(null);
// 实时播放进度
const playSeek = ref<number>(
Math.floor((player.getSeek() + statusStore.getSongOffset(musicStore.playSong?.id)) * 1000),
);
const playSeek = ref<number>(player.getSeek() + statusStore.getSongOffset(musicStore.playSong?.id));
// 实时更新播放进度
const { pause: pauseSeek, resume: resumeSeek } = useRafFn(() => {
const songId = musicStore.playSong?.id;
const offsetSeconds = statusStore.getSongOffset(songId);
const seekInSeconds = player.getSeek() + offsetSeconds;
playSeek.value = Math.floor(seekInSeconds * 1000);
const offsetTime = statusStore.getSongOffset(songId);
playSeek.value = player.getSeek() + offsetTime;
});
// 歌词主色
@@ -72,8 +69,8 @@ const amLyricsData = computed<LyricLine[]>(() => {
if (!songLyric) return [];
// 优先使用逐字歌词(YRC/TTML)
const useYrc = songLyric.yrcAMData?.length && settingStore.showYrc;
const lyrics = useYrc ? songLyric.yrcAMData : songLyric.lrcAMData;
const useYrc = songLyric.yrcData?.length && settingStore.showYrc;
const lyrics = useYrc ? songLyric.yrcData : songLyric.lrcData;
// 简单检查歌词有效性
if (!Array.isArray(lyrics) || lyrics.length === 0) return [];
@@ -84,8 +81,9 @@ const amLyricsData = computed<LyricLine[]>(() => {
// 进度跳转
const jumpSeek = (line: any) => {
if (!line?.line?.lyricLine?.startTime) return;
const time = msToS(line.line.lyricLine.startTime);
player.setSeek(time);
const time = line.line.lyricLine.startTime;
const offsetMs = statusStore.getSongOffset(musicStore.playSong?.id);
player.setSeek(time - offsetMs);
player.play();
};

View File

@@ -21,7 +21,7 @@
>
<Transition name="fade" mode="out-in">
<div
:key="musicStore.songLyric.lrcData?.[0]?.content"
:key="musicStore.songLyric.lrcData?.[0]?.words?.[0]?.word"
class="lyric-content"
@after-enter="lyricsScroll(statusStore.lyricIndex)"
@after-leave="lyricsScroll(statusStore.lyricIndex)"
@@ -34,7 +34,7 @@
<!-- 倒计时 -->
<CountDown
:start="0"
:duration="musicStore.songLyric.yrcData[0].time || 0"
:duration="musicStore.songLyric.yrcData[0].startTime || 0"
:seek="playSeek"
:playing="statusStore.playStatus"
/>
@@ -50,7 +50,7 @@
// on: statusStore.lyricIndex === index,
// 当播放时间大于等于当前歌词的开始时间
on:
(playSeek >= item.time && playSeek < item.endTime) ||
(playSeek >= item.startTime && playSeek < item.endTime) ||
statusStore.lyricIndex === index,
'is-bg': item.isBG,
'is-duet': item.isDuet,
@@ -58,60 +58,62 @@
]"
:style="{
filter: settingStore.lyricsBlur
? (playSeek >= item.time && playSeek < item.endTime) ||
? (playSeek >= item.startTime && playSeek < item.endTime) ||
statusStore.lyricIndex === index
? 'blur(0)'
: `blur(${Math.min(Math.abs(statusStore.lyricIndex - index) * 1.8, 10)}px)`
: 'blur(0)',
}"
@click="jumpSeek(item.time)"
@click="jumpSeek(item.startTime)"
>
<!-- 歌词 -->
<div class="content">
<div
v-for="(text, textIndex) in item.contents"
v-for="(text, textIndex) in item.words"
:key="textIndex"
:class="{
'content-text': true,
'content-long':
settingStore.showYrcLongEffect &&
text.duration >= 1.5 &&
text.endTime - item.startTime >= 1500 &&
playSeek <= text.endTime,
'end-with-space': text.endsWithSpace,
'end-with-space': text.word.endsWith(' ') || text.startTime === 0,
}"
>
<span class="word" :lang="getLyricLanguage(text.content)">
{{ text.content }}
<span class="word" :lang="getLyricLanguage(text.word)">
{{ text.word }}
</span>
<span
class="filler"
:style="getYrcStyle(text, index)"
:lang="getLyricLanguage(text.content)"
:lang="getLyricLanguage(text.word)"
>
{{ text.content }}
{{ text.word }}
</span>
</div>
</div>
<!-- 翻译 -->
<span v-if="item.tran && settingStore.showTran" class="tran" lang="en">
{{ item.tran }}
<span v-if="item.translatedLyric && settingStore.showTran" class="tran" lang="en">
{{ item.translatedLyric }}
</span>
<!-- 音译 -->
<span v-if="item.roma && settingStore.showRoma" class="roma" lang="en">
{{ item.roma }}
<span v-if="item.romanLyric && settingStore.showRoma" class="roma" lang="en">
{{ item.romanLyric }}
</span>
<!-- 间奏倒计时 -->
<div
v-if="
settingStore.countDownShow &&
item.time > 0 &&
musicStore.songLyric.yrcData[index + 1]?.time - item.endTime >= 10
item.startTime > 0 &&
(musicStore.songLyric.yrcData[index + 1]?.startTime || 0) - item.endTime >= 10000
"
class="count-down-content"
>
<CountDown
:start="item.endTime"
:duration="musicStore.songLyric.yrcData[index + 1]?.time - item.endTime || 0"
:duration="
(musicStore.songLyric.yrcData[index + 1]?.startTime || 0) - item.endTime
"
:seek="playSeek"
:playing="statusStore.playStatus"
/>
@@ -125,7 +127,7 @@
<!-- 倒计时 -->
<CountDown
:start="0"
:duration="musicStore.songLyric.lrcData[0].time || 0"
:duration="musicStore.songLyric.lrcData[0].startTime || 0"
:seek="playSeek"
:playing="statusStore.playStatus"
/>
@@ -140,17 +142,19 @@
? `blur(${Math.min(Math.abs(statusStore.lyricIndex - index) * 1.8, 10)}px)`
: 'blur(0)',
}"
@click="jumpSeek(item.time)"
@click="jumpSeek(item.startTime)"
>
<!-- 歌词 -->
<span class="content" :lang="getLyricLanguage(item.content)">{{ item.content }}</span>
<span class="content" :lang="getLyricLanguage(item.words?.[0]?.word)">
{{ item.words?.[0]?.word }}
</span>
<!-- 翻译 -->
<span v-if="item.tran && settingStore.showTran" class="tran" lang="en">
{{ item.tran }}
<span v-if="item.translatedLyric && settingStore.showTran" class="tran" lang="en">
{{ item.translatedLyric }}
</span>
<!-- 音译 -->
<span v-if="item.roma && settingStore.showRoma" class="roma" lang="en">
{{ item.roma }}
<span v-if="item.romanLyric && settingStore.showRoma" class="roma" lang="en">
{{ item.romanLyric }}
</span>
</div>
<div class="placeholder" />
@@ -164,14 +168,15 @@
</template>
<script setup lang="ts">
import type { LyricContentType } from "@/types/main";
import { LyricWord } from "@applemusic-like-lyrics/lyric";
import { NScrollbar } from "naive-ui";
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
import player from "@/utils/player";
import { getLyricLanguage } from "@/utils/lyric";
import { usePlayer } from "@/utils/player";
import { getLyricLanguage } from "@/utils/format";
import { isElectron } from "@/utils/env";
import LyricMenu from "./LyricMenu.vue";
const player = usePlayer();
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
@@ -201,7 +206,7 @@ const lyricsScroll = (index: number) => {
const container = lrcItemDom.parentElement;
if (!container) return;
// 调整滚动的距离
const scrollDistance = lrcItemDom.offsetTop - container.offsetTop - 80;
const scrollDistance = lrcItemDom.offsetTop - container.offsetTop - 100;
// 开始滚动
if (settingStore.lyricsScrollPosition === "center") {
lrcItemDom?.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -222,7 +227,7 @@ const INACTIVE_NO_ANIMATION_STYLE = { opacity: 0 } as const;
* @param lyricIndex 歌词索引
* @returns 逐字歌词动画样式
*/
const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => {
const getYrcStyle = (wordData: LyricWord, lyricIndex: number) => {
// 获取当前歌词行数据
const currentLine = musicStore.songLyric.yrcData[lyricIndex];
// 缓存 playSeek 值,避免多次访问响应式变量
@@ -230,14 +235,14 @@ const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => {
// 判断当前行是否处于激活状态
const isLineActive =
(currentSeek >= currentLine.time && currentSeek < currentLine.endTime) ||
(currentSeek >= currentLine.startTime && currentSeek < currentLine.endTime) ||
statusStore.lyricIndex === lyricIndex;
// 如果当前歌词行不是激活状态,返回固定样式,避免不必要的计算
if (!isLineActive) {
if (settingStore.showYrcAnimation) {
// 判断单词是否已经唱过:已唱过保持填充状态(0%),未唱到保持未填充状态(100%)
const hasPlayed = currentSeek >= wordData.time + wordData.duration;
const hasPlayed = currentSeek >= wordData.endTime;
return {
WebkitMaskPositionX: hasPlayed ? "0%" : "100%",
};
@@ -249,24 +254,28 @@ const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => {
// 激活状态的样式计算
if (settingStore.showYrcAnimation) {
// 如果播放状态不是加载中,且当前单词的时间加上持续时间减去播放进度大于 0
if (statusStore.playLoading === false && wordData.time + wordData.duration - currentSeek > 0) {
if (statusStore.playLoading === false && wordData.endTime - currentSeek > 0) {
return {
transitionDuration: `0s, 0s, 0.35s`,
transitionDelay: `0ms`,
WebkitMaskPositionX: `${
100 - Math.max(((currentSeek - wordData.time) / wordData.duration) * 100, 0)
100 -
Math.max(
((currentSeek - wordData.startTime) / (wordData.endTime - wordData.startTime)) * 100,
0,
)
}%`,
};
}
// 预计算时间差,避免重复计算
const timeDiff = wordData.time - currentSeek;
const timeDiff = wordData.startTime - currentSeek;
return {
transitionDuration: `${wordData.duration}ms, ${wordData.duration * 0.8}ms, 0.35s`,
transitionDelay: `${timeDiff}ms, ${timeDiff + wordData.duration * 0.5}ms, 0ms`,
transitionDuration: `${wordData.endTime - wordData.startTime}ms, ${(wordData.endTime - wordData.startTime) * 0.8}ms, 0.35s`,
transitionDelay: `${timeDiff}ms, ${timeDiff + (wordData.endTime - wordData.startTime) * 0.5}ms, 0ms`,
};
} else {
// 无动画模式:根据单词时间判断透明度
return statusStore.playLoading === false && wordData.time >= currentSeek
return statusStore.playLoading === false && wordData.startTime >= currentSeek
? { opacity: 0 }
: { opacity: 1 };
}
@@ -276,7 +285,8 @@ const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => {
const jumpSeek = (time: number) => {
if (!time) return;
lrcMouseStatus.value = false;
player.setSeek(time);
const offsetMs = statusStore.getSongOffset(musicStore.playSong?.id);
player.setSeek(time - offsetMs);
player.play();
};
@@ -390,8 +400,8 @@ onBeforeUnmount(() => {
top: 0;
transform: none;
will-change: -webkit-mask-position-x, transform, opacity;
// padding: 2px 8px;
// margin: -2px -8px;
padding: 0.3em 0;
margin: -0.3em 0;
mask-image: linear-gradient(
to right,
rgb(0, 0, 0) 45.4545454545%,
@@ -410,7 +420,7 @@ onBeforeUnmount(() => {
opacity 0.3s,
filter 0.3s,
margin 0.3s,
padding 0.3s !important;
padding 0.3s;
}
&.end-with-space {
margin-right: 12px;

View File

@@ -81,13 +81,7 @@
<template #footer>
<n-grid :cols="2" x-gap="16" class="playlist-menu">
<n-gi>
<n-button
:focusable="false"
size="large"
strong
secondary
@click="player.cleanPlayList()"
>
<n-button :focusable="false" size="large" strong secondary @click="cleanPlayList">
<template #icon>
<SvgIcon name="DeleteSweep" />
</template>
@@ -117,8 +111,9 @@
<script setup lang="ts">
import { useStatusStore, useDataStore } from "@/stores";
import type { VirtualListInst } from "naive-ui";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const player = usePlayer();
const dataStore = useDataStore();
const statusStore = useStatusStore();
@@ -138,6 +133,20 @@ const playListData = computed(() => {
const scrollToItem = (index: number, behavior: "smooth" | "auto" = "smooth") => {
playListRef.value?.scrollTo({ index, behavior });
};
// 清空播放列表
const cleanPlayList = () => {
window.$dialog.warning({
title: "清空播放列表",
content: "确认清空全部播放列表吗?",
positiveText: "确认",
negativeText: "取消",
onPositiveClick: () => {
player.cleanPlayList();
window.$message.success("播放列表已清空");
},
});
};
</script>
<style lang="scss" scoped>

View File

@@ -161,8 +161,8 @@
vertical
>
<div class="time">
<n-text depth="2">{{ secondsToTime(statusStore.currentTime) }}</n-text>
<n-text depth="2">{{ secondsToTime(statusStore.duration) }}</n-text>
<n-text depth="2">{{ msToTime(statusStore.currentTime) }}</n-text>
<n-text depth="2">{{ msToTime(statusStore.duration) }}</n-text>
</div>
<!-- 定时关闭 -->
<n-tag
@@ -189,7 +189,7 @@
<script setup lang="ts">
import type { DropdownOption } from "naive-ui";
import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores";
import { secondsToTime, convertSecondsToTime } from "@/utils/time";
import { msToTime, convertSecondsToTime } from "@/utils/time";
import { renderIcon, coverLoaded } from "@/utils/helper";
import { toLikeSong } from "@/utils/auth";
import {
@@ -199,9 +199,10 @@ import {
openJumpArtist,
openPlaylistAdd,
} from "@/utils/modal";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const router = useRouter();
const player = usePlayer();
const dataStore = useDataStore();
const musicStore = useMusicStore();
const statusStore = useStatusStore();
@@ -262,6 +263,7 @@ const isShowLyrics = computed(() => {
const isHasLrc = musicStore.isHasLrc;
return (
isHasLrc &&
!statusStore.lyricLoading &&
settingStore.barLyricShow &&
musicStore.playSong.type !== "radio" &&
statusStore.playStatus &&
@@ -275,9 +277,10 @@ const instantLyrics = computed(() => {
const content = isYrc
? musicStore.songLyric.yrcData[statusStore.lyricIndex]
: musicStore.songLyric.lrcData[statusStore.lyricIndex];
return content?.tran && settingStore.showTran
? `${content?.content} ${content?.tran} `
: content?.content;
const contentStr = content?.words?.map((v) => v.word).join("") || "";
return content?.translatedLyric && settingStore.showTran
? `${contentStr} ${content?.translatedLyric} `
: contentStr || "";
});
</script>
@@ -292,7 +295,7 @@ const instantLyrics = computed(() => {
background-color: var(--surface-container-hex);
// background-color: rgba(var(--surface-container), 0.28);
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: 1fr auto 1fr;
align-items: center;
transition: bottom 0.3s;
z-index: 10;
@@ -320,8 +323,8 @@ const instantLyrics = computed(() => {
.play-data {
display: flex;
flex-direction: row;
max-width: 100%;
overflow: hidden;
max-width: 640px;
.cover {
position: relative;
display: flex;
@@ -372,7 +375,8 @@ const instantLyrics = computed(() => {
.info {
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
min-width: 0;
.data {
display: flex;
align-items: center;
@@ -380,18 +384,21 @@ const instantLyrics = computed(() => {
.name {
font-weight: bold;
font-size: 16px;
width: max-content;
max-width: calc(100% - 100px);
flex: 0 1 auto;
width: auto;
min-width: 0;
transition: color 0.3s;
}
.n-tag {
margin-left: 8px;
flex-shrink: 0;
}
.like {
color: var(--primary-hex);
margin-left: 8px;
transition: transform 0.3s;
cursor: pointer;
flex-shrink: 0;
&:hover {
transform: scale(1.15);
}
@@ -402,6 +409,7 @@ const instantLyrics = computed(() => {
.more {
margin-left: 8px;
cursor: pointer;
flex-shrink: 0;
}
}
.artists {
@@ -445,6 +453,7 @@ const instantLyrics = computed(() => {
flex-direction: row;
justify-content: center;
align-items: center;
margin: 0 40px;
.play-pause {
--n-width: 44px;
--n-height: 44px;
@@ -487,6 +496,8 @@ const instantLyrics = computed(() => {
}
}
.play-menu {
margin-left: auto;
max-width: 640px;
.time-container {
margin-right: 8px;
.n-tag {

View File

@@ -79,8 +79,9 @@
import { useMusicStore, useStatusStore } from "@/stores";
import { coverLoaded } from "@/utils/helper";
import { debounce, isObject } from "lodash-es";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const player = usePlayer();
const musicStore = useMusicStore();
const statusStore = useStatusStore();

View File

@@ -146,10 +146,11 @@ const loadMoreComment = () => {
watch(
() => songId.value,
() => {
if (!isShowComment.value) {
commentData.value = [];
return;
}
commentData.value = [];
commentHotData.value = [];
commentPage.value = 1;
commentHasMore.value = true;
if (!isShowComment.value) return;
getHotCommentData();
getAllComment();
},

View File

@@ -76,9 +76,9 @@
</div>
<!-- 进度条 -->
<div class="slider">
<span>{{ secondsToTime(statusStore.currentTime) }}</span>
<span>{{ msToTime(statusStore.currentTime) }}</span>
<PlayerSlider :show-tooltip="false" />
<span>{{ secondsToTime(statusStore.duration) }}</span>
<span>{{ msToTime(statusStore.duration) }}</span>
</div>
</div>
<n-flex class="right" align="center" justify="end">
@@ -92,11 +92,12 @@
<script setup lang="ts">
import { useMusicStore, useStatusStore, useDataStore } from "@/stores";
import { secondsToTime } from "@/utils/time";
import { msToTime } from "@/utils/time";
import { openDownloadSong, openPlaylistAdd } from "@/utils/modal";
import { toLikeSong } from "@/utils/auth";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const player = usePlayer();
const dataStore = useDataStore();
const musicStore = useMusicStore();
const statusStore = useStatusStore();

View File

@@ -1,10 +1,14 @@
<template>
<div :class="['player-data', settingStore.playerType, { center }]">
<div :class="['player-data', settingStore.playerType, { center, light }]">
<!-- 名称 -->
<div class="name">
<span class="name-text text-hidden">{{ musicStore.playSong.name || "未知曲目" }}</span>
<!-- 额外信息 -->
<div v-if="statusStore.playUblock || musicStore.playSong.pc" class="extra-info">
<n-flex
v-if="statusStore.playUblock || musicStore.playSong.pc"
class="extra-info"
align="center"
>
<n-popover :show-arrow="false" placement="right" raw>
<template #trigger>
<SvgIcon
@@ -21,58 +25,78 @@
}}
</div>
</n-popover>
</div>
</n-flex>
</div>
<!-- 别名 -->
<span v-if="musicStore.playSong.alia" class="alia text-hidden">
{{ musicStore.playSong.alia }}
</span>
<!-- 歌手 -->
<div v-if="musicStore.playSong.type !== 'radio'" class="artists">
<SvgIcon :depth="3" name="Artist" size="20" />
<div v-if="Array.isArray(musicStore.playSong.artists)" class="ar-list">
<n-flex :align="center ? 'center' : undefined" size="small" vertical>
<!-- 播放状态 -->
<n-flex
v-if="settingStore.showPlayMeta && !light"
class="play-meta"
size="small"
align="center"
>
<!-- 音质 -->
<span class="meta-item">{{
statusStore.playUblock || !statusStore.songQuality ? "未知音质" : statusStore.songQuality
}}</span>
<!-- 歌词模式 -->
<span class="meta-item">{{ lyricMode }}</span>
<!-- 是否在线 -->
<span class="meta-item">
{{ musicStore.playSong.path ? "LOCAL" : "ONLINE" }}
</span>
</n-flex>
<!-- 歌手 -->
<div v-if="musicStore.playSong.type !== 'radio'" class="artists">
<SvgIcon :depth="3" name="Artist" size="20" />
<div v-if="Array.isArray(musicStore.playSong.artists)" class="ar-list">
<span
v-for="ar in musicStore.playSong.artists"
:key="ar.id"
class="ar"
@click="jumpPage({ name: 'artist', query: { id: ar.id } })"
>
{{ ar.name }}
</span>
</div>
<div v-else class="ar-list">
<span class="ar">{{ musicStore.playSong.artists || "未知艺术家" }}</span>
</div>
</div>
<div v-else class="artists">
<SvgIcon :depth="3" name="Artist" size="20" />
<div class="ar-list">
<span class="ar">{{ musicStore.playSong.dj?.creator || "未知艺术家" }}</span>
</div>
</div>
<!-- 专辑 -->
<div v-if="musicStore.playSong.type !== 'radio'" class="album">
<SvgIcon :depth="3" name="Album" size="20" />
<span
v-for="ar in musicStore.playSong.artists"
:key="ar.id"
class="ar"
@click="jumpPage({ name: 'artist', query: { id: ar.id } })"
v-if="isObject(musicStore.playSong.album)"
class="name-text text-hidden"
@click="jumpPage({ name: 'album', query: { id: musicStore.playSong.album.id } })"
>
{{ ar.name }}
{{ musicStore.playSong.album?.name || "未知专辑" }}
</span>
<span v-else class="name-text text-hidden">
{{ musicStore.playSong.album || "未知专辑" }}
</span>
</div>
<div v-else class="ar-list">
<span class="ar">{{ musicStore.playSong.artists || "未知艺术家" }}</span>
</div>
</div>
<div v-else class="artists">
<SvgIcon :depth="3" name="Artist" size="20" />
<div class="ar-list">
<span class="ar">{{ musicStore.playSong.dj?.creator || "未知艺术家" }}</span>
</div>
</div>
<!-- 专辑 -->
<div v-if="musicStore.playSong.type !== 'radio'" class="album">
<SvgIcon :depth="3" name="Album" size="20" />
<span
v-if="isObject(musicStore.playSong.album)"
class="name-text text-hidden"
@click="jumpPage({ name: 'album', query: { id: musicStore.playSong.album.id } })"
<!-- 电台 -->
<div
v-if="musicStore.playSong.type === 'radio'"
class="dj"
@click="jumpPage({ name: 'dj', query: { id: musicStore.playSong.dj?.id } })"
>
{{ musicStore.playSong.album?.name || "未知专辑" }}
</span>
<span v-else class="name-text text-hidden">
{{ musicStore.playSong.album || "未知专辑" }}
</span>
</div>
<!-- 电台 -->
<div
v-if="musicStore.playSong.type === 'radio'"
class="dj"
@click="jumpPage({ name: 'dj', query: { id: musicStore.playSong.dj?.id } })"
>
<SvgIcon :depth="3" name="Podcast" size="20" />
<span class="name-text text-hidden">{{ musicStore.playSong.dj?.name || "播客电台" }}</span>
</div>
<SvgIcon :depth="3" name="Podcast" size="20" />
<span class="name-text text-hidden">{{ musicStore.playSong.dj?.name || "播客电台" }}</span>
</div>
</n-flex>
</div>
</template>
@@ -84,6 +108,8 @@ import { debounce, isObject } from "lodash-es";
defineProps<{
center?: boolean;
theme?: string;
// 少量数据模式
light?: boolean;
}>();
const router = useRouter();
@@ -91,6 +117,15 @@ const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 当前歌词模式
const lyricMode = computed(() => {
if (settingStore.showYrc) {
if (statusStore.usingTTMLLyric) return "TTML";
if (musicStore.isHasYrc) return "YRC";
}
return musicStore.isHasLrc ? "LRC" : "NO-LRC";
});
const jumpPage = debounce(
(go: RouteLocationRaw) => {
if (!go) return;
@@ -134,14 +169,13 @@ const jumpPage = debounce(
}
}
.alia {
margin: 6px 0 6px 2px;
margin: 6px 0 6px 4px;
opacity: 0.6;
font-size: 18px;
line-clamp: 1;
-webkit-line-clamp: 1;
}
.artists {
margin-top: 2px;
display: flex;
align-items: center;
.n-icon {
@@ -178,7 +212,6 @@ const jumpPage = debounce(
}
.album,
.dj {
margin-top: 2px;
font-size: 16px;
display: flex;
align-items: center;
@@ -196,6 +229,16 @@ const jumpPage = debounce(
}
}
}
.play-meta {
padding: 4px 4px;
opacity: 0.6;
.meta-item {
font-size: 12px;
border-radius: 8px;
padding: 2px 6px;
border: 1px solid rgba(var(--main-color), 0.6);
}
}
&.record {
width: 100%;
padding: 0 80px 0 24px;
@@ -219,6 +262,20 @@ const jumpPage = debounce(
text-align: center;
}
}
&.light {
.name {
.name-text {
line-clamp: 1;
-webkit-line-clamp: 1;
}
.extra-info {
display: none;
}
}
.alia {
display: none;
}
}
}
.player-tip {
max-width: 240px;

View File

@@ -47,7 +47,7 @@
v-if="!statusStore.personalFmMode"
:value="dataStore.playList?.length ?? 0"
:show="settingStore.showPlaylistCount"
:max="999"
:max="9999"
:style="{
marginRight: settingStore.showPlaylistCount ? '12px' : null,
}"
@@ -65,8 +65,9 @@ import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/
import { openAutoClose, openChangeRate, openEqualizer } from "@/utils/modal";
import { isElectron } from "@/utils/env";
import { renderIcon } from "@/utils/helper";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const player = usePlayer();
const dataStore = useDataStore();
const musicStore = useMusicStore();
const statusStore = useStatusStore();

View File

@@ -18,11 +18,12 @@
<script setup lang="ts">
import { useStatusStore } from "@/stores";
import player from "@/utils/player";
import { secondsToTime } from "@/utils/time";
import { msToTime } from "@/utils/time";
import { usePlayer } from "@/utils/player";
withDefaults(defineProps<{ showTooltip?: boolean }>(), { showTooltip: true });
const player = usePlayer();
const statusStore = useStatusStore();
// 拖动时的临时值
@@ -66,7 +67,7 @@ const endDrag = () => {
// 格式化提示
const formatTooltip = (value: number) => {
return `${secondsToTime(value)} / ${secondsToTime(statusStore.duration)}`;
return `${msToTime(value)} / ${msToTime(statusStore.duration)}`;
};
</script>

View File

@@ -39,12 +39,13 @@
<script setup lang="ts">
import { useStatusStore, useDataStore, useSettingStore } from "@/stores";
import { searchDefault } from "@/api/search";
import SearchInpMenu from "@/components/Menu/SearchInpMenu.vue";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
import { songDetail } from "@/api/song";
import { formatSongsList } from "@/utils/format";
import SearchInpMenu from "@/components/Menu/SearchInpMenu.vue";
const router = useRouter();
const player = usePlayer();
const dataStore = useDataStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
@@ -149,6 +150,11 @@ const toSearch = async (key: any, type: string = "keyword") => {
query: { id: key?.id },
});
break;
case "share":
if (key?.realType && key?.id) {
toSearch({ id: key.id }, key.realType);
}
break;
default:
break;
}

View File

@@ -99,6 +99,10 @@ const searchSuggestionsType = {
name: "歌单",
icon: "MusicList",
},
share: {
name: "分享的内容",
icon: "Link",
},
};
// 获取搜索建议
@@ -126,11 +130,53 @@ const calcSearchSuggestHeights = () => {
}
};
// 识别链接类型
const getLinkType = (val: string) => {
const regex = /music\.163\.com\/(?:#\/)?(song|playlist|album|artist)\?id=(\d+)/;
const match = val.match(regex);
if (match) {
const typeMap: Record<string, string> = {
song: "songs",
playlist: "playlists",
album: "albums",
artist: "artists",
};
const nameMap: Record<string, string> = {
song: "歌曲",
playlist: "歌单",
album: "专辑",
artist: "歌手",
};
return {
type: typeMap[match[1]],
typeName: nameMap[match[1]],
id: match[2],
};
}
return null;
};
// 搜索框改变
watchDebounced(
() => statusStore.searchInputValue,
(val) => {
if (!val || val === "" || !settingStore.useOnlineService) return;
// 识别链接
const linkData = getLinkType(val);
if (linkData) {
searchSuggestData.value = {
order: ["share"],
share: [
{
name: `前往分享的${linkData.typeName}`,
id: linkData.id,
realType: linkData.type,
},
],
};
nextTick(calcSearchSuggestHeights);
return;
}
getSearchSuggest(val);
},
{ debounce: 300 },

View File

@@ -36,6 +36,21 @@
</n-card>
</n-collapse-transition>
</div>
<div class="set-list">
<n-h3 prefix="bar"> 社区与资讯 </n-h3>
<n-flex class="link">
<n-card
v-for="(item, index) in communityData"
:key="index"
class="link-item"
hoverable
@click="openLink(item.url)"
>
<SvgIcon :name="item.icon" :size="26" />
<n-text class="name"> {{ item.name }} </n-text>
</n-card>
</n-flex>
</div>
<div class="set-list">
<n-h3 prefix="bar"> 历史版本 </n-h3>
<n-collapse-transition :show="oldVersion?.length > 0">
@@ -59,21 +74,6 @@
</n-collapse>
</n-collapse-transition>
</div>
<div class="set-list">
<n-h3 prefix="bar"> 社区与资讯 </n-h3>
<n-flex class="link">
<n-card
v-for="(item, index) in communityData"
:key="index"
class="link-item"
hoverable
@click="openLink(item.url)"
>
<SvgIcon :name="item.icon" :size="26" />
<n-text class="name"> {{ item.name }} </n-text>
</n-card>
</n-flex>
</div>
</div>
</template>
@@ -89,6 +89,11 @@ const statusStore = useStatusStore();
// 社区数据
const communityData = [
{
name: "加入交流群",
url: "https://qm.qq.com/cgi-bin/qm/qr?k=2-cVSf1bE0AvAehCib00qFEFdUvPaJ_k&jump_from=webapi&authKey=1NEhib9+GsmsXVo2rCc0IbRaVHeeRXJJ0gbsyKDcIwDdAzYySOubkFCvkV32+7Cw",
icon: "QQ",
},
{
name: "GitHub",
url: packageJson.github,

View File

@@ -95,6 +95,27 @@
</div>
<n-switch class="set" v-model:value="settingStore.menuShowCover" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">显示歌曲音质</n-text>
<n-text class="tip" :depth="3">是否列表中显示歌曲音质</n-text>
</div>
<n-switch class="set" v-model:value="settingStore.showSongQuality" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">显示特权标签</n-text>
<n-text class="tip" :depth="3">是否显示如 VIPEP 等特权标签</n-text>
</div>
<n-switch class="set" v-model:value="settingStore.showSongPrivilegeTag" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">显示原唱翻唱标签</n-text>
<n-text class="tip" :depth="3">是否显示歌曲原唱翻唱标签</n-text>
</div>
<n-switch class="set" v-model:value="settingStore.showSongOriginalTag" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">开启页面缓存</n-text>

View File

@@ -246,14 +246,12 @@
</n-card>
</div>
<div class="set-list">
<n-h3 prefix="bar">
歌词内容
</n-h3>
<n-h3 prefix="bar"> 歌词内容 </n-h3>
<n-card class="set-item">
<div class="label">
<n-text class="name">
启用在线 TTML 歌词
<n-tag type="warning" size="small" round style="display: inline; vertical-align: middle;">Beta</n-tag>
<n-tag type="warning" size="small" round> Beta </n-tag>
</n-text>
<n-text class="tip" :depth="3">
是否从 AMLL TTML DB 获取歌词如有TTML
@@ -435,7 +433,7 @@
/>
</n-flex>
</n-card>
<n-card class="set-item">
<!-- <n-card class="set-item">
<div class="label">
<n-text class="name">显示逐字歌词</n-text>
<n-text class="tip" :depth="3">是否显示桌面歌词逐字效果</n-text>
@@ -446,7 +444,7 @@
class="set"
@update:value="saveDesktopLyricConfig"
/>
</n-card>
</n-card> -->
<n-card class="set-item">
<div class="label">
<n-text class="name">显示翻译</n-text>
@@ -545,10 +543,11 @@ import { cloneDeep, isEqual } from "lodash-es";
import { isElectron } from "@/utils/env";
import { openLyricExclude } from "@/utils/modal";
import { LyricConfig } from "@/types/desktop-lyric";
import defaultDesktopLyricConfig from "@/assets/data/lyricConfig";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
import { SelectOption } from "naive-ui";
import defaultDesktopLyricConfig from "@/assets/data/lyricConfig";
const player = usePlayer();
const statusStore = useStatusStore();
const settingStore = useSettingStore();

View File

@@ -1,10 +1,10 @@
<template>
<div class="setting">
<div class="set-left">
<div class="title">
<n-flex class="title" :size="0" vertical>
<n-h1>设置</n-h1>
<n-text :depth="3">个性化与全局设置</n-text>
</div>
</n-flex>
<!-- 设置菜单 -->
<n-menu
v-model:value="activeKey"

View File

@@ -66,13 +66,6 @@
</div>
<n-switch v-model:value="settingStore.playSongDemo" class="set" :round="false" />
</n-card>
<n-card v-if="isElectron" class="set-item">
<div class="label">
<n-text class="name">音乐解锁</n-text>
<n-text class="tip" :depth="3">在无法正常播放时进行替换可能会与原曲不符</n-text>
</div>
<n-switch v-model:value="settingStore.useSongUnlock" class="set" :round="false" />
</n-card>
<n-card v-if="isElectron" class="set-item">
<div class="label">
<n-text class="name">音频输出设备</n-text>
@@ -87,6 +80,35 @@
/>
</n-card>
</div>
<div v-if="isElectron" class="set-list">
<n-h3 prefix="bar">
音乐解锁
<n-tag type="warning" size="small" round>Beta</n-tag>
</n-h3>
<n-card class="set-item">
<div class="label">
<n-text class="name">音乐解锁</n-text>
<n-text class="tip" :depth="3"> 在无法正常播放时进行替换可能会与原曲不符 </n-text>
</div>
<n-switch v-model:value="settingStore.useSongUnlock" class="set" :round="false" />
</n-card>
<!-- 音源配置 -->
<n-card class="set-item">
<div class="label">
<n-text class="name">音源配置</n-text>
<n-text class="tip" :depth="3"> 配置歌曲解锁的音源顺序或是否启用 </n-text>
</div>
<n-button
:disabled="!settingStore.useSongUnlock"
type="primary"
strong
secondary
@click="openSongUnlockManager"
>
配置
</n-button>
</n-card>
</div>
<div class="set-list">
<n-h3 prefix="bar"> 播放器 </n-h3>
<n-card class="set-item">
@@ -155,6 +177,13 @@
</div>
<n-switch v-model:value="settingStore.barLyricShow" class="set" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">展示播放状态信息</n-text>
<n-text class="tip" :depth="3">展示当前歌曲及歌词的状态信息</n-text>
</div>
<n-switch v-model:value="settingStore.showPlayMeta" class="set" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">播放列表歌曲数量</n-text>
@@ -220,8 +249,10 @@ import { isLogin } from "@/utils/auth";
import { renderOption } from "@/utils/helper";
import { isElectron } from "@/utils/env";
import { uniqBy } from "lodash";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
import { openSongUnlockManager } from "@/utils/modal";
const player = usePlayer();
const settingStore = useSettingStore();
// 输出设备数据

View File

@@ -247,7 +247,7 @@ export const useDataStore = defineStore("data", {
async getUserLikePlaylist() {
if (!isLogin() || !this.userData.userId) return;
const result = await musicDB.getItem("likeSongsList");
return result;
return result as { detail: CoverType; data: SongType[] } | null;
},
// 更改云盘歌单
async setCloudPlayList(data: SongType[]) {

View File

@@ -1,18 +1,13 @@
import { defineStore } from "pinia";
import type { LyricLine } from "@applemusic-like-lyrics/core";
import type { SongType, LyricType } from "@/types/main";
import type { SongType } from "@/types/main";
import { isElectron } from "@/utils/env";
import { cloneDeep } from "lodash-es";
import { SongLyric } from "@/types/lyric";
interface MusicState {
playSong: SongType;
playPlaylistId: number;
songLyric: {
lrcData: LyricType[];
yrcData: LyricType[];
lrcAMData: LyricLine[];
yrcAMData: LyricLine[];
};
songLyric: SongLyric;
personalFM: {
playIndex: number;
list: SongType[];
@@ -46,8 +41,6 @@ export const useMusicStore = defineStore("music", {
songLyric: {
lrcData: [], // 普通歌词
yrcData: [], // 逐字歌词
lrcAMData: [], // 普通歌词-AM
yrcAMData: [], // 逐字歌词-AM
},
// 私人FM数据
personalFM: {
@@ -85,35 +78,29 @@ export const useMusicStore = defineStore("music", {
},
},
actions: {
// 恢复默认音乐数据
/** 重置音乐数据 */
resetMusicData() {
this.playSong = { ...defaultMusicData };
this.songLyric = {
lrcData: [],
yrcData: [],
lrcAMData: [],
yrcAMData: [],
};
this.setSongLyric({ lrcData: [], yrcData: [] }, true);
if (isElectron) {
window.electron.ipcRenderer.send("play-song-change", undefined);
}
},
/**
* 设置/更新歌曲歌词数据
* @param updates 部分或完整歌词数据
* @param replace 是否覆盖true用提供的数据覆盖并为缺省字段置空false合并更新
*/
setSongLyric(updates: Partial<MusicState["songLyric"]>, replace: boolean = false) {
setSongLyric(updates: Partial<SongLyric>, replace: boolean = false) {
if (replace) {
this.songLyric = {
lrcData: updates.lrcData ?? [],
yrcData: updates.yrcData ?? [],
lrcAMData: updates.lrcAMData ?? [],
yrcAMData: updates.yrcAMData ?? [],
};
} else {
this.songLyric = {
lrcData: updates.lrcData ?? this.songLyric.lrcData,
yrcData: updates.yrcData ?? this.songLyric.yrcData,
lrcAMData: updates.lrcAMData ?? this.songLyric.lrcAMData,
yrcAMData: updates.yrcAMData ?? this.songLyric.yrcAMData,
};
}
// 更新歌词窗口数据
@@ -122,6 +109,7 @@ export const useMusicStore = defineStore("music", {
"play-lyric-change",
cloneDeep({
songId: this.playSong?.id,
lyricLoading: false,
lrcData: this.songLyric.lrcData ?? [],
yrcData: this.songLyric.yrcData ?? [],
}),

View File

@@ -1,5 +1,6 @@
import { defineStore } from "pinia";
import { keywords, regexes } from "@/assets/data/exclude";
import { SongUnlockServer } from "@/utils/songManager";
export interface SettingState {
/** 明暗模式 */
@@ -99,6 +100,8 @@ export interface SettingState {
songVolumeFadeTime: number;
/** 是否使用解灰 */
useSongUnlock: boolean;
/** 歌曲解锁音源 */
songUnlockServer: { key: SongUnlockServer; enabled: boolean }[];
/** 显示倒计时 */
countDownShow: boolean;
/** 显示歌词条 */
@@ -169,6 +172,14 @@ export interface SettingState {
excludeRegexes: string[];
/** 显示默认本地路径 */
showDefaultLocalPath: boolean;
/** 展示当前歌曲歌词状态信息 */
showPlayMeta: boolean;
/** 显示歌曲音质 */
showSongQuality: boolean;
/** 显示歌曲特权标签 */
showSongPrivilegeTag: boolean;
/** 显示原唱翻唱标签 */
showSongOriginalTag: boolean;
}
export const useSettingStore = defineStore("setting", {
@@ -199,6 +210,11 @@ export const useSettingStore = defineStore("setting", {
songVolumeFade: true,
songVolumeFadeTime: 300,
useSongUnlock: true,
songUnlockServer: [
{ key: SongUnlockServer.BODIAN, enabled: true },
{ key: SongUnlockServer.GEQUBAO, enabled: true },
{ key: SongUnlockServer.NETEASE, enabled: true },
],
countDownShow: true,
barLyricShow: true,
playerType: "cover",
@@ -247,6 +263,10 @@ export const useSettingStore = defineStore("setting", {
proxyPort: 80,
useRealIP: false,
realIP: "",
showPlayMeta: false,
showSongQuality: true,
showSongPrivilegeTag: true,
showSongOriginalTag: true,
}),
getters: {
/**

View File

@@ -1,5 +1,5 @@
import { defineStore } from "pinia";
import type { SortType } from "@/types/main";
import { QualityType,type SortType } from "@/types/main";
import type { PlayModeType, RGB, ColorScheme } from "@/types/main";
interface StatusState {
@@ -48,6 +48,8 @@ interface StatusState {
pureLyricMode: boolean;
/** 是否使用 TTML 歌词 */
usingTTMLLyric: boolean;
/** 当前歌曲音质 */
songQuality: QualityType | undefined;
/** 当前播放索引 */
playIndex: number;
/** 歌词播放索引 */
@@ -115,6 +117,7 @@ export const useStatusStore = defineStore("status", {
songCoverTheme: {},
pureLyricMode: false,
usingTTMLLyric: false,
songQuality: undefined,
spectrumsData: [],
playIndex: -1,
lyricIndex: -1,
@@ -175,16 +178,26 @@ export const useStatusStore = defineStore("status", {
},
},
actions: {
/** 获取指定歌曲的偏移(默认 0 */
/**
* 获取指定歌曲的偏移
* 单位:毫秒
*/
getSongOffset(songId?: number): number {
if (!songId) return 0;
return this.currentTimeOffsetMap?.[songId] ?? 0;
const offsetTime = this.currentTimeOffsetMap?.[songId] ?? 0;
return Math.floor(offsetTime * 1000);
},
/** 设置指定歌曲的偏移 */
/**
* 设置指定歌曲的偏移
* @param songId 歌曲 id
* @param offset 偏移量(单位:毫秒)
*/
setSongOffset(songId?: number, offset: number = 0) {
if (!songId) return;
if (!this.currentTimeOffsetMap) this.currentTimeOffsetMap = {};
const fixed = Number(offset.toFixed(2));
// 将毫秒转换为秒存储保留2位小数
const offsetSeconds = offset / 1000;
const fixed = Number(offsetSeconds.toFixed(2));
if (fixed === 0) {
// 为 0 时移除记录,避免占用空间
delete this.currentTimeOffsetMap[songId];
@@ -192,11 +205,15 @@ export const useStatusStore = defineStore("status", {
this.currentTimeOffsetMap[songId] = fixed;
}
},
/** 调整指定歌曲的偏移(增量) */
incSongOffset(songId?: number, delta: number = 0.5) {
/**
* 调整指定歌曲的偏移(增量)
* @param songId 歌曲 id
* @param delta 偏移增量(单位:毫秒,默认 500ms
*/
incSongOffset(songId?: number, delta: number = 500) {
if (!songId) return;
const current = this.getSongOffset(songId);
const next = Number((current + delta).toFixed(2));
const next = current + delta;
if (next === 0) {
delete this.currentTimeOffsetMap[songId];
} else {

View File

@@ -3,7 +3,6 @@
padding: 0;
user-select: none;
box-sizing: border-box;
-webkit-user-drag: none;
::after {
box-sizing: border-box;
}
@@ -18,6 +17,12 @@ body {
overflow: hidden;
}
img,
video,
audio {
-webkit-user-drag: none;
}
#app {
width: 100%;
height: 100%;

109
src/types/amll.d.ts vendored
View File

@@ -1,109 +0,0 @@
/**
* AMLL (Apple Music-like Lyrics) 相关类型定义
*/
/**
* 歌词播放器引用类型
*/
export interface LyricPlayerRef {
setCurrentTime?: (time: number) => void;
setPlaying?: (playing: boolean) => void;
lyricPlayer?: { value?: any };
}
/**
* 歌词单词类型
*/
export interface LyricWord {
word: string;
startTime: number;
endTime: number;
}
/**
* 歌词行类型
*/
export interface LyricLine {
startTime: number;
endTime: number;
words: LyricWord[];
translatedLyric?: string;
romanLyric?: string;
isBG?: boolean;
isDuet?: boolean;
}
/**
* 歌词点击事件类型
*/
export interface LyricClickEvent {
line: {
getLine: () => LyricLine;
lyricLine?: LyricLine;
};
}
/**
* 弹簧参数类型
*/
export interface SpringParam {
mass: number; // 质量,影响弹簧的惯性
damping: number; // 阻尼,影响弹簧的减速速度
stiffness: number; // 刚度,影响弹簧的弹力
soft: boolean; // 是否使用软弹簧模式
}
/**
* 弹簧参数集合
*/
export interface SpringParams {
posX?: SpringParam;
posY?: SpringParam;
scale?: SpringParam;
rotation?: SpringParam;
}
/**
* 背景渲染器引用类型
*/
export interface BackgroundRenderRef {
bgRender: any;
wrapperEl?: HTMLDivElement;
}
/**
* 背景渲染器属性类型
*/
export interface BackgroundRenderProps {
album?: string;
albumIsVideo?: boolean;
fps?: number;
playing?: boolean;
flowSpeed?: number;
hasLyric?: boolean;
lowFreqVolume?: number;
renderScale?: number;
staticMode?: boolean;
renderer?: any;
}
/**
* 歌词处理器设置类型
*/
export interface LyricsProcessorSettings {
showYrc: boolean;
showRoma: boolean;
showTransl: boolean;
}
/**
* 歌曲歌词类型
*/
export interface SongLyric {
lrc?: Array<{time: number, content: string, tran?: string, roma?: string}>;
yrc?: Array<{time: number, endTime?: number, content: any[], tran?: string, roma?: string}>;
ttml?: string;
hasYrc?: boolean;
lrcAMData?: LyricLine[];
yrcAMData?: LyricLine[];
}

View File

@@ -1,4 +1,4 @@
import { LyricType } from "@/types/main";
import { type LyricLine } from "@applemusic-like-lyrics/lyric";
/** 桌面歌词数据 */
export interface LyricData {
@@ -8,13 +8,15 @@ export interface LyricData {
playStatus?: boolean;
/** 当前播放进度 */
currentTime?: number;
/** 是否正在加载歌词 */
lyricLoading?: boolean;
/** 当前播放歌曲 id用于偏移校准 */
songId?: number;
/** 当前歌曲的时间偏移(秒,正负均可) */
songOffset?: number;
/** 歌词数据 */
lrcData?: LyricType[];
yrcData?: LyricType[];
lrcData?: LyricLine[];
yrcData?: LyricLine[];
/** 歌词播放索引 */
lyricIndex?: number;
}
@@ -52,7 +54,7 @@ export interface LyricConfig {
*/
export interface RenderLine {
/** 当前整行歌词数据(用于逐字渲染) */
line: LyricType;
line: LyricLine;
/** 当前行在歌词数组中的索引 */
index: number;
/** 唯一键 */

9
src/types/lyric.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import { type LyricLine } from "@applemusic-like-lyrics/lyric";
/**
* 歌词数据类型
*/
export interface SongLyric {
lrcData: LyricLine[];
yrcData: LyricLine[];
}

View File

@@ -20,6 +20,20 @@ export type CoverSize = {
xl: string;
};
/** 音质 */
export enum QualityType {
/** Hi-Res */
HiRes = "Hi-Res", // hr
/** 无损 */
SQ = "SQ", // sq / flac
/** 高质量 */
HQ = "HQ", // h: 320kbps
/** 中质量 */
MQ = "MQ", // m: 192kbps
/** 低质量 */
LQ = "LQ", // l: 128kbps
}
export type UserType = {
id: number;
name: string;
@@ -48,7 +62,7 @@ export type SongType = {
path?: string;
pc?: boolean;
size?: number;
quality?: "Hi-Res" | "HQ" | "SQ";
quality?: QualityType;
createTime?: number;
updateTime?: number;
playCount?: number;
@@ -68,7 +82,6 @@ export type CoverType = {
count?: number;
tags?: string[];
userId?: number | null;
count?: number;
privacy?: number;
playCount?: number;
liked?: boolean;
@@ -76,7 +89,6 @@ export type CoverType = {
commentCount?: number;
shareCount?: number;
subCount?: number;
playCount?: number;
createTime?: number;
updateTime?: number;
loading?: boolean;
@@ -128,11 +140,19 @@ export type CommentType = {
*/
export type PlayModeType = "repeat" | "repeat-once" | "shuffle";
/**
*
*/
export type LyricContentType = {
/** 歌词开始时间 */
time: number;
/** 歌词结束时间 */
endTime: number;
/** 歌词持续时间 */
duration: number;
/** 歌词内容 */
content: string;
/** 是否以空格结尾 */
endsWithSpace: boolean;
};
@@ -150,7 +170,9 @@ export type LyricType = {
isBG?: boolean;
/** 是否为对唱歌词 */
isDuet?: boolean;
/** 歌词内容 */
content: string;
/** 歌词内容数组 */
contents: LyricContentType[];
};
@@ -231,14 +253,14 @@ export type UpdateLogType = {
};
// 文件信息
interface FileInfoType {
export interface FileInfoType {
url: string;
sha512: string;
size: number;
}
// 更新信息
interface UpdateInfoType {
export interface UpdateInfoType {
tag: string;
version: string;
files: FileInfoType[];

View File

@@ -1,6 +1,7 @@
import type { SongType, CoverType, ArtistType, CommentType, MetaData, CatType } from "@/types/main";
import { SongType, CoverType, ArtistType, CommentType, MetaData, CatType } from "@/types/main";
import { msToTime } from "./time";
import { flatMap, isArray, uniqBy } from "lodash-es";
import { handleSongQuality } from "./helper";
type CoverDataType = {
cover: string;
@@ -67,7 +68,9 @@ export const formatSongsList = (data: any[]): SongType[] => {
size: Number(item.size || 0),
path: item.path,
pc: !!item.pc,
quality: item?.quality,
quality: item?.path
? handleSongQuality(item.quality, "local")
: handleSongQuality(item, "online"),
playCount: Number(item.playCount || item.listenerCount || 0),
createTime: Number(item.createTime || item.publishTime) || undefined,
updateTime: Number(item.lastProgramCreateTime || item.scheduledPublishTime) || undefined,
@@ -267,3 +270,17 @@ const getCoverSizeUrl = (url: string, size: number | null = null) => {
return "/images/song.jpg?assest";
}
};
/**
* 检测歌词语言
* @param lyric 歌词内容
* @returns 语言代码("ja" | "zh-CN" | "en"
*/
export const getLyricLanguage = (lyric: string): string => {
// 判断日语 根据平假名和片假名
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(lyric)) return "ja";
// 判断简体中文 根据中日韩统一表意文字基本区
if (/[\u4e00-\u9fa5]/.test(lyric)) return "zh-CN";
// 默认英语
return "en";
};

View File

@@ -1,4 +1,4 @@
import type { SongType, UpdateLogType } from "@/types/main";
import { QualityType, SongType, UpdateLogType } from "@/types/main";
import { NTooltip, SelectOption } from "naive-ui";
import { h, VNode } from "vue";
import { useClipboard } from "@vueuse/core";
@@ -419,3 +419,44 @@ export const runIdle = (task: () => void) => {
}, 0);
}
};
/**
* 处理歌曲音质
* @param song 歌曲数据
* @param type 歌曲类型
* @returns 歌曲音质
*/
export const handleSongQuality = (
song: AnyObject | number,
type: "local" | "online" = "local",
): QualityType | undefined => {
if (type === "local" && typeof song === "number") {
if (song >= 960000) return QualityType.HiRes;
if (song >= 441000) return QualityType.SQ;
if (song >= 320000) return QualityType.HQ;
if (song >= 160000) return QualityType.MQ;
return QualityType.LQ;
}
// 含有 level 特殊处理
if( typeof song === "object" && "level" in song){
if(song.level === "hires") return QualityType.HiRes;
if(song.level === "lossless") return QualityType.SQ;
if(song.level === "exhigh") return QualityType.HQ;
if(song.level === "higher") return QualityType.MQ;
if(song.level === "standard") return QualityType.LQ;
return undefined;
}
const order = [
{ key: "hr", type: QualityType.HiRes },
{ key: "sq", type: QualityType.SQ },
{ key: "h", type: QualityType.HQ },
{ key: "m", type: QualityType.MQ },
{ key: "l", type: QualityType.LQ },
];
for (const itemKey of order) {
if (song[itemKey.key] && Number(song[itemKey.key].br) > 0) {
return itemKey.type;
}
}
return undefined;
};

View File

@@ -3,13 +3,14 @@ import { useEventListener } from "@vueuse/core";
import { openUserAgreement } from "@/utils/modal";
import { debounce } from "lodash-es";
import { isElectron } from "./env";
import { usePlayer } from "@/utils/player";
import packageJson from "@/../package.json";
import player from "@/utils/player";
import log from "./log";
// 应用初始化时需要执行的操作
const init = async () => {
// init pinia-data
const player = usePlayer();
const dataStore = useDataStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
@@ -58,6 +59,7 @@ const initEventListener = () => {
// 键盘事件
const keyDownEvent = debounce((event: KeyboardEvent) => {
const player = usePlayer();
const shortcutStore = useShortcutStore();
const target = event.target as HTMLElement;
// 排除元素

View File

@@ -2,7 +2,7 @@ import { isElectron } from "./env";
import { openSetting, openUpdateApp } from "./modal";
import { useMusicStore, useDataStore, useStatusStore } from "@/stores";
import { toLikeSong } from "./auth";
import player from "./player";
import { usePlayer } from "./player";
import { cloneDeep } from "lodash-es";
import { getPlayerInfo } from "./player-utils/song";
import { SettingType } from "@/types/main";
@@ -17,6 +17,7 @@ const closeUpdateStatus = () => {
const initIpc = () => {
try {
if (!isElectron) return;
const player = usePlayer();
// 播放
window.electron.ipcRenderer.on("play", () => player.play());
// 暂停
@@ -54,7 +55,7 @@ const initIpc = () => {
"update-desktop-lyric-data",
cloneDeep({
playStatus: statusStore.playStatus,
playName: getPlayerInfo() ?? "未知歌曲",
playName: getPlayerInfo(),
currentTime: statusStore.currentTime,
songId: musicStore.playSong?.id,
songOffset: statusStore.getSongOffset(musicStore.playSong?.id),

View File

@@ -1,573 +0,0 @@
import { LyricLine, parseLrc, parseTTML, parseYrc, TTMLLyric } from "@applemusic-like-lyrics/lyric";
import type { LyricType } from "@/types/main";
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
import { SettingState } from "@/stores/setting";
import { msToS } from "./time";
/** 获取排除关键词 */
const getExcludeKeywords = (settings: SettingState = useSettingStore()): string[] => {
if (!settings.enableExcludeLyrics) return [];
return settings.excludeKeywords;
};
/** 获取排除正则表达式 */
const getExcludeRegexes = (settings: SettingState = useSettingStore()): RegExp[] => {
if (!settings.enableExcludeLyrics) return [];
return settings.excludeRegexes.map((regex) => new RegExp(regex));
};
/**
* 检测歌词是否排除
* @param line 歌词行
* @returns 是否排除
*/
const isLyricExcluded = (line: string): boolean => {
const settingStore = useSettingStore();
if (!settingStore.enableExcludeLyrics) {
return false;
}
const excludeKeywords = getExcludeKeywords(settingStore);
const excludeRegexes = getExcludeRegexes(settingStore);
return (
excludeKeywords.some((keyword) => line.includes(keyword)) ||
excludeRegexes.some((regex) => regex.test(line))
);
};
/**
* 恢复默认歌词数据
*/
export const resetSongLyric = () => {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
// 重置歌词数据
musicStore.setSongLyric({}, true);
statusStore.usingTTMLLyric = false;
// 标记为加载中(切歌时防止显示上一首歌词)
statusStore.lyricLoading = true;
// 重置歌词索引
statusStore.lyricIndex = -1;
};
/**
* 解析歌词数据
* @param lyricData 歌词数据
* @param skipExclude 是否跳过排除
* @returns 歌词数据
*/
export const parsedLyricsData = (lyricData: any, skipExclude: boolean = false): void => {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
if (lyricData.code !== 200) {
resetSongLyric();
return;
}
let lrcData: LyricType[] = [];
let yrcData: LyricType[] = [];
// 处理后歌词
let lrcParseData: LyricLine[] = [];
let tlyricParseData: LyricLine[] = [];
let romalrcParseData: LyricLine[] = [];
let yrcParseData: LyricLine[] = [];
let ytlrcParseData: LyricLine[] = [];
let yromalrcParseData: LyricLine[] = [];
// 普通歌词
if (lyricData?.lrc?.lyric) {
lrcParseData = parseLrc(lyricData.lrc.lyric);
lrcData = parseLrcData(lrcParseData, skipExclude);
// 其他翻译
if (lyricData?.tlyric?.lyric) {
tlyricParseData = parseLrc(lyricData.tlyric.lyric);
lrcData = alignLyrics(lrcData, parseLrcData(tlyricParseData), "tran");
}
if (lyricData?.romalrc?.lyric) {
romalrcParseData = parseLrc(lyricData.romalrc.lyric);
lrcData = alignLyrics(lrcData, parseLrcData(romalrcParseData), "roma");
}
}
// 逐字歌词
if (lyricData?.yrc?.lyric) {
yrcParseData = parseYrc(lyricData.yrc.lyric);
yrcData = parseYrcData(yrcParseData, skipExclude);
// 其他翻译
if (lyricData?.ytlrc?.lyric) {
ytlrcParseData = parseLrc(lyricData.ytlrc.lyric);
yrcData = alignLyrics(yrcData, parseLrcData(ytlrcParseData), "tran");
}
if (lyricData?.yromalrc?.lyric) {
yromalrcParseData = parseLrc(lyricData.yromalrc.lyric);
yrcData = alignLyrics(yrcData, parseLrcData(yromalrcParseData), "roma");
}
}
musicStore.setSongLyric(
{
lrcData,
yrcData,
lrcAMData: parseAMData(lrcParseData, tlyricParseData, romalrcParseData, skipExclude),
yrcAMData: parseAMData(yrcParseData, ytlrcParseData, yromalrcParseData, skipExclude),
},
true,
);
// 重置歌词索引
statusStore.lyricIndex = -1;
// 歌词已加载完成
statusStore.lyricLoading = false;
};
/**
* 解析LRC歌词数据
* @param lrcData LRC歌词数据
* @param skipExclude 是否跳过排除
* @returns LRC歌词数据
*/
export const parseLrcData = (lrcData: LyricLine[], skipExclude: boolean = false): LyricType[] => {
if (!lrcData) return [];
// 数据处理
const lrcList = lrcData
.map((line) => {
const words = line.words;
const time = msToS(words[0].startTime);
const content = words[0].word.trim();
// 排除内容
if (!content || (!skipExclude && isLyricExcluded(content))) {
return null;
}
return {
time,
content,
};
})
.filter((line): line is LyricType => line !== null);
// 筛选出非空数据并返回
return lrcList;
};
/**
* 解析逐字歌词数据
* @param yrcData 逐字歌词数据
* @param skipExclude 是否跳过排除
* @returns 逐字歌词数据
*/
export const parseYrcData = (yrcData: LyricLine[], skipExclude: boolean = false): LyricType[] => {
if (!yrcData) return [];
// 数据处理
const yrcList = yrcData
.map((line) => {
const words = line.words;
const time = msToS(words[0].startTime);
const endTime = msToS(words[words.length - 1].endTime);
const contents = words.map((word) => {
return {
time: msToS(word.startTime),
endTime: msToS(word.endTime),
duration: msToS(word.endTime - word.startTime),
content: word.word.trim(),
endsWithSpace: word.word.endsWith(" "),
};
});
// 完整歌词
const contentStr = contents
.map((word) => word.content + (word.endsWithSpace ? " " : ""))
.join("");
// 排除内容
if (!contentStr || (!skipExclude && isLyricExcluded(contentStr))) {
return null;
}
return {
time,
endTime,
content: contentStr,
contents,
};
})
.filter((line): line is LyricType => line !== null);
return yrcList;
};
/**
* 歌词内容对齐
* @param lyrics 歌词数据
* @param otherLyrics 其他歌词数据
* @param key 对齐类型
* @returns 对齐后的歌词数据
*/
export const alignLyrics = (
lyrics: LyricType[],
otherLyrics: LyricType[],
key: "tran" | "roma",
): LyricType[] => {
const lyricsData = lyrics;
if (lyricsData.length && otherLyrics.length) {
lyricsData.forEach((v: LyricType) => {
otherLyrics.forEach((x: LyricType) => {
if (v.time === x.time || Math.abs(v.time - x.time) < 0.6) {
v[key] = x.content;
}
});
});
}
return lyricsData;
};
/**
* 对齐AM歌词
* @param lyrics 歌词数据
* @param otherLyrics 其他歌词数据
* @param key 对齐类型
* @returns 对齐后的歌词数据
*/
export const alignAMLyrics = (
lyrics: LyricLine[],
otherLyrics: LyricLine[],
key: "translatedLyric" | "romanLyric",
): LyricLine[] => {
const lyricsData = lyrics;
if (lyricsData.length && otherLyrics.length) {
lyricsData.forEach((v: LyricLine) => {
otherLyrics.forEach((x: LyricLine) => {
if (v.startTime === x.startTime || Math.abs(v.startTime - x.startTime) < 0.6) {
v[key] = x.words.map((word) => word.word).join("");
}
});
});
}
return lyricsData;
};
/**
* 处理本地歌词
* @param lyric 歌词内容
* @param format 歌词格式
*/
export const parseLocalLyric = (lyric: string, format: "lrc" | "ttml") => {
const statusStore = useStatusStore();
if (!lyric) {
resetSongLyric();
return;
}
switch (format) {
case "lrc":
parseLocalLyricLrc(lyric);
statusStore.usingTTMLLyric = false;
break;
case "ttml":
parseLocalLyricAM(lyric);
statusStore.usingTTMLLyric = true;
break;
}
};
/**
* 解析本地LRC歌词
* @param lyric LRC格式的歌词内容
*/
const parseLocalLyricLrc = (lyric: string) => {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 解析
const lrc: LyricLine[] = parseLrc(lyric);
const lrcData: LyricType[] = parseLrcData(lrc, !settingStore.enableExcludeLocalLyrics);
// 处理结果
const lrcDataParsed: LyricType[] = [];
// 翻译提取
for (let i = 0; i < lrcData.length; i++) {
// 当前歌词
const lrcItem = lrcData[i];
// 是否具有翻译
const existingObj = lrcDataParsed.find((v) => v.time === lrcItem.time);
if (existingObj) {
existingObj.tran = lrcItem.content;
} else {
lrcDataParsed.push(lrcItem);
}
}
// 更新歌词
musicStore.setSongLyric(
{
lrcData: lrcDataParsed,
lrcAMData: lrcDataParsed.map((line, index, lines) => ({
words: [{ startTime: line.time, endTime: 0, word: line.content }],
startTime: line.time * 1000,
endTime: lines[index + 1]?.time * 1000,
translatedLyric: line.tran ?? "",
romanLyric: line.roma ?? "",
isBG: false,
isDuet: false,
})),
yrcData: [],
yrcAMData: [],
},
true,
);
// 重置歌词索引
statusStore.lyricIndex = -1;
// 歌词已加载完成
statusStore.lyricLoading = false;
};
/**
* 解析本地AM歌词
* @param lyric AM格式的歌词内容
*/
const parseLocalLyricAM = (lyric: string) => {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
const skipExcludeLocal = !settingStore.enableExcludeLocalLyrics;
const skipExcludeTTML = !settingStore.enableTTMLLyric;
const skipExclude = skipExcludeLocal || skipExcludeTTML;
const ttml = parseTTML(lyric);
const yrcAMData = parseTTMLToAMLL(ttml, skipExclude);
const yrcData = parseTTMLToYrc(ttml, skipExclude);
musicStore.setSongLyric(
{
lrcData: yrcData,
lrcAMData: yrcAMData,
yrcAMData,
yrcData,
},
true,
);
// 重置歌词索引
statusStore.lyricIndex = -1;
// 歌词已加载完成
statusStore.lyricLoading = false;
};
/**
* 处理 AM 歌词
* @param lrcData LRC歌词数据
* @param tranData 翻译歌词数据
* @param romaData 罗马音歌词数据
* @param skipExclude 是否跳过排除
* @returns AM歌词数据
*/
const parseAMData = (
lrcData: LyricLine[],
tranData?: LyricLine[],
romaData?: LyricLine[],
skipExclude: boolean = false,
) => {
let lyricData = lrcData
.map((line, index, lines) => {
// 获取歌词文本内容
const content = line.words
.map((word) => word.word)
.join("")
.trim();
// 排除包含关键词的内容
if (!content || (!skipExclude && isLyricExcluded(content))) {
return null;
}
return {
words: line.words,
startTime: line.words[0]?.startTime ?? 0,
endTime:
lines[index + 1]?.words?.[0]?.startTime ??
line.words?.[line.words.length - 1]?.endTime ??
Infinity,
translatedLyric: "",
romanLyric: "",
isBG: line.isBG ?? false,
isDuet: line.isDuet ?? false,
};
})
.filter((line): line is NonNullable<typeof line> => line !== null);
if (tranData) {
lyricData = alignAMLyrics(lyricData, tranData, "translatedLyric");
}
if (romaData) {
lyricData = alignAMLyrics(lyricData, romaData, "romanLyric");
}
return lyricData;
};
/**
* 从TTML格式解析歌词并转换为AMLL格式
* @param ttmlContent TTML格式的歌词内容
* @param skipExclude 是否跳过排除
* @returns AMLL格式的歌词行数组
*/
export const parseTTMLToAMLL = (
ttmlContent: TTMLLyric,
skipExclude: boolean = false,
): LyricLine[] => {
if (!ttmlContent) return [];
try {
const validLines = ttmlContent.lines
.filter((line) => line && typeof line === "object" && Array.isArray(line.words))
.map((line) => {
const words = line.words
.filter((word) => word && typeof word === "object")
.map((word) => ({
word: String(word.word || " "),
startTime: Number(word.startTime) || 0,
endTime: Number(word.endTime) || 0,
}));
if (!words.length) return null;
// 获取歌词文本内容
const content = words
.map((word) => word.word)
.join("")
.trim();
// 排除包含关键词的内容
if (!content || (!skipExclude && isLyricExcluded(content))) {
return null;
}
const startTime = line.startTime || words[0].startTime;
const endTime = line.endTime || words[words.length - 1].endTime;
return {
words,
startTime,
endTime,
translatedLyric: String(line.translatedLyric || ""),
romanLyric: String(line.romanLyric || ""),
isBG: Boolean(line.isBG),
isDuet: Boolean(line.isDuet),
};
})
.filter((line): line is LyricLine => line !== null);
return validLines;
} catch (error) {
console.error("TTML parsing error:", error);
return [];
}
};
/**
* 从TTML格式解析歌词并转换为默认Yrc格式
* @param ttmlContent TTML格式的歌词内容
* @param skipExclude 是否跳过排除
* @returns 默认Yrc格式的歌词行数组
*/
export const parseTTMLToYrc = (
ttmlContent: TTMLLyric,
skipExclude: boolean = false,
): LyricType[] => {
if (!ttmlContent) return [];
try {
// 数据处理
const yrcList = ttmlContent.lines
.map((line) => {
const words = line.words;
const time = msToS(words[0].startTime);
const endTime = msToS(words[words.length - 1].endTime);
const contents = words.map((word) => {
return {
time: msToS(word.startTime),
endTime: msToS(word.endTime),
duration: msToS(word.endTime - word.startTime),
content: word.word.trim(),
endsWithSpace: word.word.endsWith(" "),
};
});
// 完整歌词
const contentStr = contents
.map((word) => word.content + (word.endsWithSpace ? " " : ""))
.join("");
// 排除内容
if (!contentStr || (!skipExclude && isLyricExcluded(contentStr))) {
return null;
}
return {
time,
endTime,
content: contentStr,
contents,
tran: line.translatedLyric || "",
roma: line.romanLyric || "",
isBG: line.isBG,
isDuet: line.isDuet,
};
})
.filter((line) => line !== null);
return yrcList;
} catch (error) {
console.error("TTML parsing to yrc error:", error);
return [];
}
};
// 检测语言
export const getLyricLanguage = (lyric: string): string => {
// 判断日语 根据平假名和片假名
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(lyric)) return "ja";
// 判断简体中文 根据中日韩统一表意文字基本区
if (/[\u4e00-\u9fa5]/.test(lyric)) return "zh-CN";
// 默认英语
return "en";
};
/**
* 计算歌词索引
* - 普通歌词(LRC):沿用当前按开始时间定位的算法
* - 逐字歌词(YRC):当播放时间位于某句 [time, endTime) 区间内时,索引为该句;
* 若下一句开始时间落在上一句区间(对唱重叠),仍保持上一句索引,直到上一句结束。
*/
export const calculateLyricIndex = (currentTime: number): number => {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 应用实时偏移(按歌曲 id 记忆) + 0.3s(解决对唱时歌词延迟问题)
const songId = musicStore.playSong?.id as number | undefined;
const playSeek = currentTime + statusStore.getSongOffset(songId) + 0.3;
// 选择歌词类型
const useYrc = !!(settingStore.showYrc && musicStore.songLyric.yrcData.length);
const lyrics = useYrc ? musicStore.songLyric.yrcData : musicStore.songLyric.lrcData;
// 无歌词时
if (!lyrics || !lyrics.length) return -1;
// 普通歌词:保持原有计算方式
if (!useYrc) {
const idx = lyrics.findIndex((v) => (v?.time ?? 0) >= playSeek);
const index = idx === -1 ? lyrics.length - 1 : idx - 1;
return index;
}
// 逐字歌词(并发最多三句同时存在):
// - 计算在播放进度下处于激活区间的句子集合 activeIndices[time, endTime)
// - 若激活数 >= 3仅保留最后三句作为并发显示允许三句同时有效否则保持最后两句
// - 索引取该并发集合中较早的一句(保持“上一句”高亮)
// - 若无激活句:首句之前返回 -1否则回退到最近一句
const firstStart = lyrics[0]?.time ?? 0;
if (playSeek < firstStart) {
return -1;
}
const activeIndices: number[] = [];
for (let i = 0; i < lyrics.length; i++) {
const start = lyrics[i]?.time ?? 0;
const end = lyrics[i]?.endTime ?? Infinity;
if (playSeek >= start && playSeek < end) {
activeIndices.push(i);
}
}
if (activeIndices.length === 0) {
// 不在任何句子的区间里:退回到最近一句(按开始时间)
const nextIdx = lyrics.findIndex((v) => (v?.time ?? 0) > playSeek);
const index = nextIdx === -1 ? lyrics.length - 1 : nextIdx - 1;
return index;
}
if (activeIndices.length === 1) {
return activeIndices[0];
}
// 激活句 >= 2如果达到三句或更多限制为最后三句并发否则保持最后两句
const concurrent = activeIndices.length >= 3 ? activeIndices.slice(-3) : activeIndices.slice(-2);
return concurrent[0];
};

414
src/utils/lyricManager.ts Normal file
View File

@@ -0,0 +1,414 @@
import { useStatusStore, useMusicStore, useSettingStore } from "@/stores";
import { songLyric, songLyricTTML } from "@/api/song";
import { type SongLyric } from "@/types/lyric";
import { type LyricLine, parseLrc, parseTTML, parseYrc } from "@applemusic-like-lyrics/lyric";
import { isElectron } from "./env";
import { isEmpty } from "lodash-es";
class LyricManager {
/**
* 在线歌词请求序列
* 每次发起新请求递增
*/
private lyricReqSeq = 0;
/**
* 当前有效的请求序列
* 用于校验返回是否属于当前歌曲的最新请求
*/
private activeLyricReq = 0;
/**
* 重置当前歌曲的歌词数据
* 包括清空歌词数据、重置歌词索引、关闭 TTMLL 歌词等
*/
private resetSongLyric() {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
// 重置歌词数据
musicStore.setSongLyric({}, true);
statusStore.usingTTMLLyric = false;
// 重置歌词索引
statusStore.lyricIndex = -1;
}
/**
* 歌词内容对齐
* @param lyrics 歌词数据
* @param otherLyrics 其他歌词数据
* @param key 对齐类型
* @returns 对齐后的歌词数据
*/
private alignLyrics(
lyrics: LyricLine[],
otherLyrics: LyricLine[],
key: "translatedLyric" | "romanLyric",
): LyricLine[] {
const lyricsData = lyrics;
if (lyricsData.length && otherLyrics.length) {
lyricsData.forEach((v: LyricLine) => {
otherLyrics.forEach((x: LyricLine) => {
if (v.startTime === x.startTime || Math.abs(v.startTime - x.startTime) < 0.6) {
v[key] = x.words.map((word) => word.word).join("");
}
});
});
}
return lyricsData;
}
/**
* 对齐本地歌词
* @param lyrics 本地歌词数据
* @param otherLyrics 其他歌词数据
* @returns 对齐后的本地歌词数据
*/
private alignLocalLyrics(lyricData: SongLyric): SongLyric {
// 同一时间的两/三行分别作为主句、翻译、音译
const toTime = (line: LyricLine) => Number(line?.startTime ?? line?.words?.[0]?.startTime ?? 0);
// 取内容
const toText = (line: LyricLine) => String(line?.words?.[0]?.word || "").trim();
const lrc = lyricData.lrcData || [];
if (!lrc.length) return lyricData;
// 按开始时间分组,时间差 < 0.6s 视为同组
const sorted = [...lrc].sort((a, b) => toTime(a) - toTime(b));
const groups: LyricLine[][] = [];
for (const line of sorted) {
const st = toTime(line);
const last = groups[groups.length - 1]?.[0];
if (last && Math.abs(st - toTime(last)) < 0.6) groups[groups.length - 1].push(line);
else groups.push([line]);
}
// 组装:第 1 行主句;第 2 行翻译;第 3 行音译;不调整时长
const aligned = groups.map((group) => {
const base = { ...group[0] } as LyricLine;
const tran = group[1] ? toText(group[1]) : "";
const roma = group[2] ? toText(group[2]) : "";
if (!base.translatedLyric) base.translatedLyric = tran;
if (!base.romanLyric) base.romanLyric = roma;
return base;
});
return { lrcData: aligned, yrcData: lyricData.yrcData };
}
/**
* 处理在线歌词
* @param id 歌曲 ID
* @returns 歌词数据
*/
private async handleOnlineLyric(id: number): Promise<SongLyric> {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 请求序列
const req = this.activeLyricReq;
// 最终结果
const result: SongLyric = { lrcData: [], yrcData: [] };
// 是否采用了 TTML
let ttmlAdopted = false;
// 过期判断
const isStale = () => this.activeLyricReq !== req || musicStore.playSong?.id !== id;
// 处理 TTML 歌词
const adoptTTML = async () => {
if (!settingStore.enableTTMLLyric) return;
const ttmlContent = await songLyricTTML(id);
if (isStale()) return;
if (!ttmlContent || typeof ttmlContent !== "string") return;
const parsed = parseTTML(ttmlContent);
const lines = parsed?.lines || [];
if (!lines.length) return;
result.yrcData = lines;
ttmlAdopted = true;
};
// 处理 LRC 歌词
const adoptLRC = async () => {
const data = await songLyric(id);
if (isStale()) return;
if (!data || data.code !== 200) return;
let lrcLines: LyricLine[] = [];
let yrcLines: LyricLine[] = [];
// 普通歌词
if (data?.lrc?.lyric) {
lrcLines = parseLrc(data.lrc.lyric) || [];
// 普通歌词翻译
if (data?.tlyric?.lyric)
lrcLines = this.alignLyrics(lrcLines, parseLrc(data.tlyric.lyric), "translatedLyric");
// 普通歌词音译
if (data?.romalrc?.lyric)
lrcLines = this.alignLyrics(lrcLines, parseLrc(data.romalrc.lyric), "romanLyric");
}
// 逐字歌词
if (data?.yrc?.lyric) {
yrcLines = parseYrc(data.yrc.lyric) || [];
// 逐字歌词翻译
if (data?.ytlrc?.lyric)
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.ytlrc.lyric), "translatedLyric");
// 逐字歌词音译
if (data?.yromalrc?.lyric)
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.yromalrc.lyric), "romanLyric");
}
if (lrcLines.length) result.lrcData = lrcLines;
// 如果没有 TTML则采用 网易云 YRC
if (!result.yrcData.length && yrcLines.length) {
result.yrcData = yrcLines;
}
// 先返回一次,避免 TTML 请求过慢
const lyricData = this.handleLyricExclude(result);
this.setFinalLyric(lyricData, req);
};
// 设置 TTML
await Promise.allSettled([adoptTTML(), adoptLRC()]);
statusStore.usingTTMLLyric = ttmlAdopted;
return result;
}
/**
* 处理本地歌词
* @param path 本地歌词路径
* @returns 歌词数据
*/
private async handleLocalLyric(path: string): Promise<SongLyric> {
try {
const statusStore = useStatusStore();
const { lyric, format }: { lyric?: string; format?: "lrc" | "ttml" } =
await window.electron.ipcRenderer.invoke("get-music-lyric", path);
if (!lyric) return { lrcData: [], yrcData: [] };
// TTML 直接返回
if (format === "ttml") {
const ttml = parseTTML(lyric);
const lines = ttml?.lines || [];
statusStore.usingTTMLLyric = true;
return { lrcData: [], yrcData: lines };
}
// 解析本地歌词并对其
const lrcLines = parseLrc(lyric);
const aligned = this.alignLocalLyrics({ lrcData: lrcLines, yrcData: [] });
statusStore.usingTTMLLyric = false;
return aligned;
} catch {
return { lrcData: [], yrcData: [] };
}
}
/**
* 检测本地歌词覆盖
* @param id 歌曲 ID
* @returns 歌词数据
*/
private async checkLocalLyricOverride(id: number): Promise<SongLyric> {
const statusStore = useStatusStore();
const settingStore = useSettingStore();
const { localLyricPath } = settingStore;
if (!isElectron || !localLyricPath.length) return { lrcData: [], yrcData: [] };
// 从本地遍历
try {
const lyricDirs = Array.isArray(localLyricPath) ? localLyricPath.map((p) => String(p)) : [];
// 读取本地歌词
const { lrc, ttml } = await window.electron.ipcRenderer.invoke(
"read-local-lyric",
lyricDirs,
id,
);
statusStore.usingTTMLLyric = Boolean(ttml);
let lrcLines: LyricLine[] = [];
let ttmlLines: LyricLine[] = [];
// 安全解析 LRC
try {
const lrcContent = typeof lrc === "string" ? lrc : "";
if (lrcContent) {
lrcLines = parseLrc(lrcContent);
console.log("检测到本地歌词覆盖", lrcLines);
}
} catch (err) {
console.error("parseLrc 本地解析失败:", err);
lrcLines = [];
}
// 安全解析 TTML
try {
const ttmlContent = typeof ttml === "string" ? ttml : "";
if (ttmlContent) {
ttmlLines = parseTTML(ttmlContent).lines || [];
console.log("检测到本地TTML歌词覆盖", ttmlLines);
}
} catch (err) {
console.error("parseTTML 本地解析失败:", err);
statusStore.usingTTMLLyric = false;
ttmlLines = [];
}
return { lrcData: lrcLines, yrcData: ttmlLines };
} catch (error) {
console.error("读取本地歌词失败:", error);
statusStore.usingTTMLLyric = false;
return { lrcData: [], yrcData: [] };
}
}
/**
* 处理歌词排除
* @param lyricData 歌词数据
* @returns 处理后的歌词数据
*/
private handleLyricExclude(lyricData: SongLyric): SongLyric {
const statusStore = useStatusStore();
const settingStore = useSettingStore();
const { enableExcludeLyrics, excludeKeywords, excludeRegexes } = settingStore;
// 未开启排除
if (!enableExcludeLyrics) return lyricData;
// 处理正则表达式
const regexes = (excludeRegexes || []).map((r: string) => new RegExp(r));
/**
* 判断歌词是否被排除
* @param line 歌词行
* @returns 是否被排除
*/
const isExcluded = (line: LyricLine) => {
const content = (line?.words || [])
.map((w) => String(w.word || ""))
.join("")
.trim();
if (!content) return true;
return (
(excludeKeywords || []).some((k: string) => content.includes(k)) ||
regexes.some((re) => re.test(content))
);
};
/**
* 过滤排除的歌词行
* @param lines 歌词行数组
* @returns 过滤后的歌词行数组
*/
const filterLines = (lines: LyricLine[]) => (lines || []).filter((l) => !isExcluded(l));
return {
lrcData: filterLines(lyricData.lrcData || []),
yrcData:
// 若当前为 TTML 且开启排除
statusStore.usingTTMLLyric && settingStore.enableExcludeTTML
? filterLines(lyricData.yrcData || [])
: lyricData.yrcData || [],
};
}
/**
* 设置最终歌词
* @param lyricData 歌词数据
* @param req 当前歌词请求
*/
private setFinalLyric(lyricData: SongLyric, req: number) {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
// 若非本次
if (this.activeLyricReq !== req) return;
// 如果只有逐字歌词
if (lyricData.lrcData.length === 0 && lyricData.yrcData.length > 0) {
// 构成普通歌词
lyricData.lrcData = lyricData.yrcData.map((line) => ({
...line,
words: [
{
word: line.words?.map((w) => w.word)?.join("") || "",
startTime: line.startTime || 0,
endTime: line.endTime || 0,
romanWord: line.words?.map((w) => w.romanWord)?.join("") || "",
},
],
}));
}
// 设置歌词
musicStore.setSongLyric(lyricData, true);
// 结束加载状态
statusStore.lyricLoading = false;
}
/**
* 处理歌词
* @param id 歌曲 ID
* @param path 本地歌词路径(可选)
*/
public async handleLyric(id: number, path?: string) {
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 标记当前歌词请求(避免旧请求覆盖新请求)
const req = ++this.lyricReqSeq;
this.activeLyricReq = req;
try {
// 歌词加载状态
statusStore.lyricLoading = true;
// 通知桌面歌词
if (isElectron) {
window.electron.ipcRenderer.send("update-desktop-lyric-data", {
lyricLoading: true,
});
}
// 检查歌词覆盖
let lyricData = await this.checkLocalLyricOverride(id);
// 开始获取歌词
if (!isEmpty(lyricData.lrcData) || !isEmpty(lyricData.yrcData)) {
// 进行本地歌词对齐
lyricData = this.alignLocalLyrics(lyricData);
// 排除本地歌词内容
if (settingStore.enableExcludeLocalLyrics) {
lyricData = this.handleLyricExclude(lyricData);
}
} else if (path) {
lyricData = await this.handleLocalLyric(path);
// 排除本地歌词内容
if (settingStore.enableExcludeLocalLyrics) {
lyricData = this.handleLyricExclude(lyricData);
}
} else {
lyricData = await this.handleOnlineLyric(id);
// 排除内容
lyricData = this.handleLyricExclude(lyricData);
}
console.log("最终歌词数据", lyricData);
this.setFinalLyric(lyricData, req);
} catch (error) {
console.error("❌ 处理歌词失败:", error);
// 重置歌词
this.resetSongLyric();
}
}
/**
* 计算歌词索引
* - 普通歌词(LRC):沿用当前按开始时间定位的算法
* - 逐字歌词(YRC):当播放时间位于某句 [time, endTime) 区间内时,索引为该句;
* 若下一句开始时间落在上一句区间(对唱重叠),仍保持上一句索引,直到上一句结束。
*/
public calculateLyricIndex(currentTime: number): number {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 应用实时偏移(按歌曲 id 记忆) + 0.3s(解决对唱时歌词延迟问题)
const songId = musicStore.playSong?.id;
const offset = statusStore.getSongOffset(songId);
const playSeek = currentTime + offset + 300;
// 选择歌词类型
const useYrc = !!(settingStore.showYrc && musicStore.songLyric.yrcData.length);
const lyrics = useYrc ? musicStore.songLyric.yrcData : musicStore.songLyric.lrcData;
// 无歌词时
if (!lyrics || !lyrics.length) return -1;
// 获取开始时间和结束时间
const getStart = (v: LyricLine) => v.startTime || 0;
const getEnd = (v: LyricLine) => v.endTime ?? Infinity;
// 普通歌词:保持原有计算方式
if (!useYrc) {
const idx = lyrics.findIndex((v) => getStart(v) >= playSeek);
return idx === -1 ? lyrics.length - 1 : idx - 1;
}
// TTML / YRC支持对唱重叠
// 在第一句之前
if (playSeek < getStart(lyrics[0])) return -1;
// 计算在播放进度下处于激活区间的句子集合 activeIndices[time, endTime)
const activeIndices: number[] = [];
for (let i = 0; i < lyrics.length; i++) {
const start = getStart(lyrics[i]);
const end = getEnd(lyrics[i]);
if (playSeek >= start && playSeek < end) {
activeIndices.push(i);
}
}
// 不在任何区间 → 找最近的上一句
if (activeIndices.length === 0) {
const next = lyrics.findIndex((v) => getStart(v) > playSeek);
return next === -1 ? lyrics.length - 1 : next - 1;
}
// 1 句激活 → 直接返回
if (activeIndices.length === 1) return activeIndices[0];
// 多句激活(对唱)
const keepCount = activeIndices.length >= 3 ? 3 : 2;
const concurrent = activeIndices.slice(-keepCount);
return concurrent[0]; // 保持上一句(重叠时不跳)
}
}
export default new LyricManager();

View File

@@ -20,6 +20,8 @@ import ExcludeLyrics from "@/components/Modal/ExcludeLyrics.vue";
import ChangeRate from "@/components/Modal/ChangeRate.vue";
import AutoClose from "@/components/Modal/AutoClose.vue";
import Equalizer from "@/components/Modal/Equalizer.vue";
import SongUnlockManager from "@/components/Modal/SongUnlockManager.vue";
import { NScrollbar } from "naive-ui";
// 用户协议
export const openUserAgreement = () => {
@@ -294,3 +296,41 @@ export const openEqualizer = () => {
},
});
};
/**
* 打开简介弹窗
* @param content 简介内容
*/
export const openDescModal = (content: string, title: string = "歌单简介") => {
window.$modal.create({
preset: "card",
transformOrigin: "center",
autoFocus: false,
style: { width: "600px" },
title,
content: () => {
return h(
NScrollbar,
{ style: { maxHeight: "400px" } },
{
default: () =>
h("div", { style: { whiteSpace: "pre-wrap" } }, { default: () => content }),
},
);
},
});
};
/** 打开音源管理弹窗 */
export const openSongUnlockManager = () => {
window.$modal.create({
preset: "card",
transformOrigin: "center",
autoFocus: false,
style: { width: "500px" },
title: "音源管理",
content: () => {
return h(SongUnlockManager);
},
});
};

View File

@@ -1,171 +0,0 @@
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
import {
parsedLyricsData,
parseLocalLyric,
parseTTMLToAMLL,
parseTTMLToYrc,
resetSongLyric,
} from "../lyric";
import { songLyric, songLyricTTML } from "@/api/song";
import { parseTTML } from "@applemusic-like-lyrics/lyric";
import { LyricLine } from "@applemusic-like-lyrics/core";
import { LyricType } from "@/types/main";
/**
* 获取歌词
* @param id 歌曲id
*/
export const getLyricData = async (id: number) => {
const musicStore = useMusicStore();
const settingStore = useSettingStore();
const statusStore = useStatusStore();
// 切歌或重新获取时,先标记为加载中
statusStore.lyricLoading = true;
if (!id) {
statusStore.usingTTMLLyric = false;
resetSongLyric();
statusStore.lyricLoading = false;
return;
}
try {
// 检测本地歌词覆盖
const getLyric = getLyricFun(settingStore.localLyricPath, id);
// 并发请求:如果 TTML 先到并且有效,则直接采用 TTML不再等待或覆盖为 LRC
const lrcPromise = getLyric("lrc", songLyric);
// 这里的第二个 getLyric 方法不传入第二个参数(在线获取函数)表明不进行在线获取,仅获取本地
const ttmlPromise = settingStore.enableTTMLLyric ? getLyric("ttml", songLyricTTML) : getLyric("ttml");
let settled = false; // 是否已采用某一种歌词并结束加载状态
let ttmlAdopted = false; // 是否已采用 TTML
const adoptTTML = async () => {
if (!ttmlPromise) {
statusStore.usingTTMLLyric = false;
return;
}
try {
const { lyric: ttmlContent, isLocal: ttmlLocal } = await ttmlPromise;
if (!ttmlContent) {
statusStore.usingTTMLLyric = false;
return;
}
// 本地 TTML 使用 parseLocalLyric在线 TTML 使用原有解析方式
if (ttmlLocal) {
parseLocalLyric(ttmlContent, "ttml");
statusStore.usingTTMLLyric = true;
ttmlAdopted = true;
if (!settled) {
statusStore.lyricLoading = false;
settled = true;
}
console.log("✅ TTML lyrics adopted (prefer TTML)");
return;
}
// 在线 TTML 解析
const parsedResult = parseTTML(ttmlContent);
if (!parsedResult?.lines?.length) {
statusStore.usingTTMLLyric = false;
return;
}
const skipExcludeLocal = ttmlLocal && !settingStore.enableExcludeLocalLyrics;
const skipExcludeTTML = !settingStore.enableExcludeTTML;
const skipExclude = skipExcludeLocal || skipExcludeTTML;
const ttmlLyric = parseTTMLToAMLL(parsedResult, skipExclude);
const ttmlYrcLyric = parseTTMLToYrc(parsedResult, skipExclude);
const updates: Partial<{
yrcAMData: LyricLine[];
yrcData: LyricType[];
lrcData: LyricType[];
lrcAMData: LyricLine[];
}> = {};
if (ttmlLyric?.length) {
updates.yrcAMData = ttmlLyric;
// 若当前无 LRC-AM 数据,使用 TTML-AM 作为回退
if (!musicStore.songLyric.lrcAMData?.length) {
updates.lrcAMData = ttmlLyric;
}
}
if (ttmlYrcLyric?.length) {
updates.yrcData = ttmlYrcLyric;
// 若当前无 LRC 数据,使用 TTML 行级数据作为回退
if (!musicStore.songLyric.lrcData?.length) {
updates.lrcData = ttmlYrcLyric;
}
}
if (Object.keys(updates).length) {
musicStore.setSongLyric(updates);
statusStore.usingTTMLLyric = true;
ttmlAdopted = true;
if (!settled) {
statusStore.lyricLoading = false;
settled = true;
}
console.log("✅ TTML lyrics adopted (prefer TTML)");
} else {
statusStore.usingTTMLLyric = false;
}
} catch (err) {
console.error("❌ Error loading TTML lyrics:", err);
statusStore.usingTTMLLyric = false;
}
};
const adoptLRC = async () => {
try {
const { lyric: lyricRes, isLocal: lyricLocal } = await lrcPromise;
// 如果 TTML 已采用,则忽略 LRC
if (ttmlAdopted) return;
// 如果没有歌词内容,直接返回
if (!lyricRes) return;
// 本地歌词使用 parseLocalLyric在线歌词使用 parsedLyricsData
if (lyricLocal) {
parseLocalLyric(lyricRes, "lrc");
} else {
parsedLyricsData(lyricRes, !settingStore.enableExcludeLocalLyrics);
}
statusStore.usingTTMLLyric = false;
if (!settled) {
statusStore.lyricLoading = false;
settled = true;
}
console.log("✅ LRC lyrics adopted");
} catch (err) {
console.error("❌ Error loading LRC lyrics:", err);
if (!settled) statusStore.lyricLoading = false;
}
};
// 启动并发任务TTML 与 LRC 同时进行,哪个先成功就先用
void adoptLRC();
void adoptTTML();
} catch (error) {
console.error("❌ Error loading lyrics:", error);
statusStore.usingTTMLLyric = false;
resetSongLyric();
statusStore.lyricLoading = false;
}
};
/**
* 获取歌词函数生成器
* @param paths 本地歌词路径数组
* @param id 歌曲ID
* @returns 返回一个函数,该函数接受扩展名和在线获取函数作为参数
*/
const getLyricFun =
(paths: string[], id: number) =>
async (
ext: string,
getOnline?: (id: number) => Promise<string | null>,
): Promise<{ lyric: string | null; isLocal: boolean }> => {
for (const path of paths) {
const lyric = await window.electron.ipcRenderer.invoke("read-local-lyric", path, id, ext);
if (lyric) return { lyric, isLocal: true };
}
return { lyric: getOnline ? await getOnline(id) : null, isLocal: false };
};

View File

@@ -1,285 +0,0 @@
/**
* 基于 HTMLAudioElement + Web Audio 的播放器引擎
*/
export class PlayerNative {
private audio: HTMLAudioElement;
private ctx: AudioContext;
private sourceNode: MediaElementAudioSourceNode;
private gainNode: GainNode;
private analyser?: AnalyserNode;
private events: Map<string, Set<(...args: any[]) => void>> = new Map();
/**
* 构造播放器引擎
* @param ctx 可选的外部 `AudioContext`,不传则内部创建
*/
constructor(ctx?: AudioContext) {
this.audio = new Audio();
this.audio.preload = "auto";
this.audio.crossOrigin = "anonymous";
this.ctx = ctx ?? new AudioContext();
this.sourceNode = this.ctx.createMediaElementSource(this.audio);
this.gainNode = this.ctx.createGain();
this.sourceNode.connect(this.gainNode).connect(this.ctx.destination);
this.bindDomEvents();
}
/**
* 加载指定音频地址,切换歌曲时只需调用此方法即可
* @param src 音频 URL需允许跨域以启用频谱/均衡等处理)
*/
load(src: string): void {
this.audio.src = src;
// 重置并触发新加载
this.audio.load();
this.emit("loadstart");
}
/**
* 开始播放如果 `AudioContext` 处于挂起状态,将自动恢复
* @returns 播放 Promise用于捕获自动播放限制等异常
*/
async play(): Promise<void> {
if (this.ctx.state === "suspended") {
try {
await this.ctx.resume();
} catch (e) {
this.emit("error", e);
}
}
try {
await this.audio.play();
this.emit("play");
} catch (e) {
this.emit("error", e);
throw e;
}
}
/**
* 暂停播放
*/
pause(): void {
this.audio.pause();
this.emit("pause");
}
/**
* 停止播放并将进度归零
*/
stop(): void {
this.audio.pause();
this.audio.currentTime = 0;
this.emit("stop");
}
/**
* 跳转到指定秒数
* @param seconds 目标时间(秒)
*/
seek(seconds: number): void {
try {
this.audio.currentTime = Math.max(0, seconds);
this.emit("seek", seconds);
} catch (e) {
this.emit("error", e);
}
}
/**
* 设置音量0.0 ~ 1.0
* @param volume 音量值
*/
setVolume(volume: number): void {
const v = Math.min(1, Math.max(0, volume));
this.gainNode.gain.setValueAtTime(v, this.ctx.currentTime);
this.emit("volume", v);
}
/**
* 渐变到目标音量
* @param target 目标音量0.0 ~ 1.0
* @param durationMs 渐变时长(毫秒)
*/
fadeTo(target: number, durationMs: number): void {
const now = this.ctx.currentTime;
const t = Math.min(1, Math.max(0, target));
this.gainNode.gain.cancelScheduledValues(now);
this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, now);
this.gainNode.gain.linearRampToValueAtTime(t, now + durationMs / 1000);
this.emit("fade", t, durationMs);
}
/**
* 设置是否循环播放
* @param loop 是否循环
*/
setLoop(loop: boolean): void {
this.audio.loop = !!loop;
this.emit("loop", !!loop);
}
/**
* 设置播放速率
* @param rate 倍速(例如 1.0 正常速)
*/
setRate(rate: number): void {
const r = Math.max(0.25, Math.min(4, rate));
this.audio.playbackRate = r;
this.emit("rate", r);
}
/**
* 将内部音频链路连接到外部 `AnalyserNode`,用于频谱/可视化
* @param analyser 频谱分析节点
*/
connectAnalyser(analyser: AnalyserNode): void {
try {
// 重新布线gain -> analyser -> destination
this.gainNode.disconnect();
this.gainNode.connect(analyser).connect(this.ctx.destination);
this.analyser = analyser;
this.emit("analyser");
} catch (e) {
this.emit("error", e);
}
}
/**
* 获取音频总时长(秒)
*/
getDuration(): number {
return Number.isFinite(this.audio.duration) ? this.audio.duration : 0;
}
/**
* 获取当前播放进度(秒)
*/
getCurrentTime(): number {
return this.audio.currentTime || 0;
}
/**
* 获取缓冲范围集合TimeRanges
*/
getBuffered(): TimeRanges {
return this.audio.buffered;
}
/**
* 绑定原生 `HTMLAudioElement` 的事件到引擎事件系统
*/
private bindDomEvents(): void {
this.audio.addEventListener("loadstart", () => this.emit("loadstart"));
this.audio.addEventListener("canplay", () => this.emit("canplay"));
this.audio.addEventListener("canplaythrough", () => this.emit("loaded"));
this.audio.addEventListener("playing", () => this.emit("playing"));
this.audio.addEventListener("pause", () => this.emit("pause"));
this.audio.addEventListener("ended", () => this.emit("end"));
this.audio.addEventListener("waiting", () => this.emit("waiting"));
this.audio.addEventListener("stalled", () => this.emit("stalled"));
this.audio.addEventListener("error", (e) => this.emit("error", e));
this.audio.addEventListener("timeupdate", () => this.emit("time", this.audio.currentTime));
this.audio.addEventListener("progress", () => this.emit("progress", this.audio.buffered));
}
/**
* 订阅事件
* @param event 事件名
* @param handler 事件处理函数
*/
on(event: string, handler: (...args: any[]) => void): void {
if (!this.events.has(event)) this.events.set(event, new Set());
this.events.get(event)!.add(handler);
}
/**
* 取消订阅事件;不传参数则清空所有事件
* @param event 事件名(可选)
* @param handler 处理函数(可选)
*/
off(event?: string, handler?: (...args: any[]) => void): void {
if (!event) {
this.events.clear();
return;
}
if (!handler) {
this.events.get(event)?.clear();
return;
}
this.events.get(event)?.delete(handler);
}
/**
* 释放资源与断开音频链路;不会销毁全局 `AudioContext`
*/
destroy(): void {
try {
this.off();
this.audio.pause();
this.audio.src = "";
this.audio.removeAttribute("src");
// 一般用于释放媒体资源的模式
this.audio.load();
} catch {}
try {
this.sourceNode.disconnect();
} catch {}
try {
this.gainNode.disconnect();
} catch {}
try {
this.analyser?.disconnect();
} catch {}
this.emit("destroy");
}
/**
* 触发事件
* @param event 事件名
* @param args 事件参数
*/
private emit(event: string, ...args: any[]): void {
const set = this.events.get(event);
if (!set || set.size === 0) return;
set.forEach((fn) => {
try {
fn(...args);
} catch {}
});
}
}
/**
* 创建一个 PlayerNative 实例的便捷工厂
* @param ctx 可选的外部 `AudioContext`
*/
export function createPlayerNative(ctx?: AudioContext): PlayerNative {
return new PlayerNative(ctx);
}
/**
* 引擎事件类型参考(自由扩展)
*/
export type PlayerNativeEvent =
| "loadstart"
| "canplay"
| "loaded"
| "playing"
| "pause"
| "stop"
| "seek"
| "volume"
| "fade"
| "loop"
| "rate"
| "analyser"
| "time"
| "progress"
| "waiting"
| "stalled"
| "end"
| "error"
| "destroy";

View File

@@ -1,8 +1,16 @@
import { songUrl, unlockSongUrl } from "@/api/song";
import { useDataStore, useMusicStore, useSettingStore, useStatusStore } from "@/stores";
import type { SongType } from "@/types/main";
import type { QualityType, SongType } from "@/types/main";
import { isElectron } from "../env";
import { getCoverColorData } from "../color";
import { handleSongQuality } from "../helper";
export type NextPrefetchSong = {
id: number;
url: string | null;
ublock: boolean;
quality?: QualityType | undefined;
} | null;
/**
* 获取当前播放歌曲
@@ -50,7 +58,7 @@ export const getPlayerInfo = (song?: SongType, sep: string = "/"): string | null
*/
export const getOnlineUrl = async (
id: number,
): Promise<{ url: string | null; isTrial: boolean }> => {
): Promise<{ url: string | null; isTrial: boolean; quality?: QualityType | undefined }> => {
const settingStore = useSettingStore();
const res = await songUrl(id, settingStore.songLevel);
console.log(`🌐 ${id} music data:`, res);
@@ -69,8 +77,10 @@ export const getOnlineUrl = async (
.replace(/m704\.music\.126\.net/g, "m701.music.126.net");
// 若为试听且未开启试听播放,则将 url 置为空,仅标记为试听
const finalUrl = isTrial && !settingStore.playSongDemo ? null : normalizedUrl;
console.log(`🎧 ${id} music url:`, finalUrl);
return { url: finalUrl, isTrial };
// 获取音质
const quality = handleSongQuality(songData, "online");
console.log(`🎧 ${id} music url:`, finalUrl, quality);
return { url: finalUrl, isTrial, quality };
};
/**
@@ -84,23 +94,34 @@ export const getUnlockSongUrl = async (songData: SongType): Promise<string | nul
const artist = Array.isArray(songData.artists) ? songData.artists[0].name : songData.artists;
const keyWord = songData.name + "-" + artist;
if (!songId || !keyWord) return null;
const servers: any[] = [
"bodian",
"netease",
];
// 尝试解锁
const promises = servers.map(server => unlockSongUrl(songId, keyWord, server));
const results = await Promise.allSettled(promises);
// 解析结果
for (const result of results) {
if (
result.status === "fulfilled" &&
result.value.code === 200 &&
result.value.url
) {
return result.value.url;
// 获取音源列表
const settingStore = useSettingStore();
const servers = settingStore.songUnlockServer
.filter((server) => server.enabled)
.map((server) => server.key);
if (servers.length === 0) return null;
// 并发请求
const promises = servers.map((server) =>
unlockSongUrl(songId, keyWord, server)
.then((result) => ({
server,
result,
success: result.code === 200 && !!result.url,
}))
.catch((err) => {
console.error(`Unlock failed with server ${server}:`, err);
return { server, result: null, success: false };
}),
);
// 按优先级顺序处理结果
for (const p of promises) {
try {
const item = await p;
if (item.success && item.result) {
return item.result.url;
}
} catch {
continue;
}
}
return null;
@@ -130,3 +151,69 @@ export const getCoverColor = async (coverUrl: string) => {
image.remove();
};
};
/**
* 预载下一首歌曲播放地址
* @returns 预载数据
*/
export const getNextSongUrl = async (): Promise<NextPrefetchSong> => {
try {
const dataStore = useDataStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 无列表或私人FM模式直接跳过
const playList = dataStore.playList;
if (!playList?.length || statusStore.personalFmMode) {
return null;
}
// 计算下一首(循环到首)
let nextIndex = statusStore.playIndex + 1;
if (nextIndex >= playList.length) nextIndex = 0;
const nextSong = playList[nextIndex];
if (!nextSong) {
return null;
}
// 本地歌曲:直接缓存 file URL
if (nextSong.path) {
const songId = nextSong.type === "radio" ? nextSong.dj?.id : nextSong.id;
return {
id: Number(songId || nextSong.id),
url: `file://${nextSong.path}`,
ublock: false,
};
}
// 在线歌曲:优先官方,其次解灰
const songId = nextSong.type === "radio" ? nextSong.dj?.id : nextSong.id;
if (!songId) {
return null;
}
const canUnlock = isElectron && nextSong.type !== "radio" && settingStore.useSongUnlock;
// 先请求官方地址
const { url: officialUrl, isTrial, quality } = await getOnlineUrl(songId);
if (officialUrl && !isTrial) {
// 官方可播放且非试听
return { id: songId, url: officialUrl, ublock: false, quality };
} else if (canUnlock) {
// 官方失败或为试听时尝试解锁
const unlockUrl = await getUnlockSongUrl(nextSong);
if (unlockUrl) {
return { id: songId, url: unlockUrl, ublock: true };
} else if (officialUrl && settingStore.playSongDemo) {
// 解锁失败,若官方为试听且允许试听,保留官方试听地址
return { id: songId, url: officialUrl, ublock: false };
} else {
return { id: songId, url: null, ublock: false };
}
} else {
// 不可解锁,仅保留官方结果(可能为空)
return { id: songId, url: officialUrl, ublock: false };
}
} catch (error) {
console.error("Error prefetching next song url:", error);
return null;
}
};

View File

@@ -1,11 +1,11 @@
import type { SongType, PlayModeType } from "@/types/main";
import { SongType, PlayModeType } from "@/types/main";
import type { MessageReactive } from "naive-ui";
import { Howl, Howler } from "howler";
import { cloneDeep } from "lodash-es";
import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores";
import { resetSongLyric, parseLocalLyric, calculateLyricIndex } from "./lyric";
import { useIntervalFn } from "@vueuse/core";
import { calculateProgress } from "./time";
import { shuffleArray, runIdle } from "./helper";
import { shuffleArray, runIdle, handleSongQuality } from "./helper";
import { heartRateList } from "@/api/playlist";
import { formatSongsList } from "./format";
import { isLogin } from "./auth";
@@ -17,100 +17,39 @@ import {
getPlayerInfo,
getPlaySongData,
getUnlockSongUrl,
getNextSongUrl,
NextPrefetchSong,
} from "./player-utils/song";
import { isDev, isElectron } from "./env";
import { getLyricData } from "./player-utils/lyric";
// import { getLyricData } from "./player-utils/lyric";
import audioContextManager from "@/utils/player-utils/context";
import lyricManager from "./lyricManager";
import blob from "./blob";
// 播放器核心
// Howler.js
import { IFormat } from "music-metadata";
/* *允许播放格式 */
const allowPlayFormat = ["mp3", "flac", "webm", "ogg", "wav"];
/**
* 播放器核心
* Howler.js 音频库
*/
class Player {
/** 播放器 */
private player: Howl;
/** 定时器 */
private playerInterval: ReturnType<typeof setInterval> | undefined;
/** 自动关闭定时器 */
private autoCloseInterval: ReturnType<typeof setInterval> | undefined;
/** 频谱数据 */
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private dataArray: Uint8Array<ArrayBuffer> | null = null;
/** 其他数据 */
private message: MessageReactive | null = null;
/** 预载下一首歌曲播放地址缓存(仅存 URL不创建 Howl */
private nextPrefetch: { id: number; url: string | null; ublock: boolean } | null = null;
/** 并发控制:当前播放会话与初始化/切曲状态 */
private playSessionId: number = 0;
/** 是否正在切换歌曲 */
private switching: boolean = false;
/** 当前曲目重试信息(按歌曲维度计数) */
private retryInfo: { songId: number; count: number } = { songId: 0, count: 0 };
constructor() {
// 创建播放器实例
this.player = new Howl({ src: [""], format: allowPlayFormat, autoplay: false });
// 初始化媒体会话
this.initMediaSession();
// 挂载全局
window.$player = this;
}
/**
* 新建会话并返回会话 id
*/
private newSession(): number {
this.playSessionId += 1;
return this.playSessionId;
}
/**
* 检查传入会话是否过期
*/
private isStale(sessionId: number): boolean {
return sessionId !== this.playSessionId;
}
/**
* 重置底层播放器与定时器(幂等)
*/
private resetPlayerCore() {
try {
// 仅卸载当前播放器实例
if (this.player) {
this.player.stop();
this.player.off();
this.player.unload();
}
} catch {
/* empty */
}
this.cleanupAllTimers();
}
/**
* 处理播放状态
*/
private handlePlayStatus() {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
const currentSessionId = this.playSessionId;
// 清理定时器
clearInterval(this.playerInterval);
// 更新播放状态
this.playerInterval = setInterval(() => {
// 检查会话是否过期
if (currentSessionId !== this.playSessionId) {
clearInterval(this.playerInterval);
return;
}
if (!this.player.playing()) return;
private readonly playerInterval = useIntervalFn(
() => {
if (!this.player?.playing()) return;
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
const currentTime = this.getSeek();
const duration = this.player.duration();
const duration = this.getDuration();
// 计算进度条距离
const progress = calculateProgress(currentTime, duration);
// 计算歌词索引(支持 LRC 与逐字 YRC对唱重叠处理
const lyricIndex = calculateLyricIndex(currentTime);
const lyricIndex = lyricManager.calculateLyricIndex(currentTime);
// 更新状态
statusStore.$patch({ currentTime, duration, progress, lyricIndex });
// 客户端事件
@@ -130,76 +69,46 @@ class Player {
window.electron.ipcRenderer.send("set-bar", progress);
}
}
}, 250);
},
250,
{ immediate: false },
);
/** 自动关闭定时器 */
private autoCloseInterval: ReturnType<typeof setInterval> | undefined;
/** 频谱数据 */
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private dataArray: Uint8Array<ArrayBuffer> | null = null;
/** 其他数据 */
private message: MessageReactive | null = null;
/** 预载下一首歌曲播放地址缓存(仅存 URL不创建 Howl */
private nextPrefetch: NextPrefetchSong = null;
/** 当前曲目重试信息(按歌曲维度计数) */
private retryInfo: { songId: number; count: number } = { songId: 0, count: 0 };
constructor() {
// 创建播放器实例
this.player = new Howl({ src: [""], format: allowPlayFormat, autoplay: false });
// 初始化媒体会话
this.initMediaSession();
// 挂载全局
window.$player = this;
}
/**
* 预载下一首歌曲的播放地址(优先官方,失败则并发尝试解灰
* 仅缓存 URL不实例化播放器
* 重置底层播放器与定时器(幂等
*/
private async prefetchNextSongUrl() {
private resetPlayerCore() {
try {
const dataStore = useDataStore();
const statusStore = useStatusStore();
// const musicStore = useMusicStore();
const settingStore = useSettingStore();
// 无列表或私人FM模式直接跳过
const playList = dataStore.playList;
if (!playList?.length || statusStore.personalFmMode) {
this.nextPrefetch = null;
return;
// 仅卸载当前播放器实例
if (this.player) {
this.player.stop();
this.player.off();
this.player.unload();
}
// 计算下一首(循环到首)
let nextIndex = statusStore.playIndex + 1;
if (nextIndex >= playList.length) nextIndex = 0;
const nextSong = playList[nextIndex];
if (!nextSong) {
this.nextPrefetch = null;
return;
}
// 本地歌曲:直接缓存 file URL
if (nextSong.path) {
const songId = nextSong.type === "radio" ? nextSong.dj?.id : nextSong.id;
this.nextPrefetch = {
id: Number(songId || nextSong.id),
url: `file://${nextSong.path}`,
ublock: false,
};
return;
}
// 在线歌曲:优先官方,其次解灰
const songId = nextSong.type === "radio" ? nextSong.dj?.id : nextSong.id;
if (!songId) {
this.nextPrefetch = null;
return;
}
const canUnlock = isElectron && nextSong.type !== "radio" && settingStore.useSongUnlock;
// 先请求官方地址
const { url: officialUrl, isTrial } = await getOnlineUrl(songId);
if (officialUrl && !isTrial) {
// 官方可播放且非试听
this.nextPrefetch = { id: songId, url: officialUrl, ublock: false };
} else if (canUnlock) {
// 官方失败或为试听时尝试解锁
const unlockUrl = await getUnlockSongUrl(nextSong);
if (unlockUrl) {
this.nextPrefetch = { id: songId, url: unlockUrl, ublock: true };
} else if (officialUrl) {
// 解锁失败,若官方为试听且允许试听,保留官方试听地址
this.nextPrefetch = { id: songId, url: officialUrl, ublock: false };
} else {
this.nextPrefetch = { id: songId, url: null, ublock: false };
}
} else {
// 不可解锁,仅保留官方结果(可能为空)
this.nextPrefetch = { id: songId, url: officialUrl, ublock: false };
}
} catch (error) {
console.error("Error prefetching next song url:", error);
Howler.unload();
} catch {
/* empty */
}
this.cleanupAllTimers();
}
/**
* 创建播放器
@@ -215,19 +124,8 @@ class Player {
const settingStore = useSettingStore();
// 播放信息
const { id, path, type } = musicStore.playSong;
const currentSessionId = this.playSessionId;
// 检查会话是否过期
if (currentSessionId !== this.playSessionId) {
console.log("🚫 Session expired, skipping player creation");
return;
}
// 统一重置底层播放器
this.resetPlayerCore();
// 二次检查会话
if (currentSessionId !== this.playSessionId) {
console.log("🚫 Session expired after cleanup, aborting");
return;
}
// 创建播放器
this.player = new Howl({
src,
@@ -245,11 +143,8 @@ class Player {
if (!settingStore.showSpectrums) this.toggleOutputDevice();
// 自动播放
if (autoPlay) await this.play();
// 获取歌曲附加信息 - 非电台和本地
if (type !== "radio" && !path) getLyricData(id);
else resetSongLyric();
// 定时获取状态
if (!this.playerInterval) this.handlePlayStatus();
// 获取歌词数据
lyricManager.handleLyric(id, path);
// 新增播放历史
if (type !== "radio") dataStore.setHistory(musicStore.playSong);
// 获取歌曲封面主色
@@ -259,7 +154,7 @@ class Player {
// 开发模式
if (isDev) window.player = this.player;
// 预载下一首播放地址
this.prefetchNextSongUrl();
this.nextPrefetch = await getNextSongUrl();
}
/**
* 播放器事件
@@ -277,10 +172,8 @@ class Player {
const playSongData = getPlaySongData();
// 获取配置
const { seek } = options;
const currentSessionId = this.playSessionId;
// 初次加载
this.player.once("load", () => {
if (currentSessionId !== this.playSessionId) return;
// 允许跨域
if (settingStore.showSpectrums) {
const audioDom = this.getAudioDom();
@@ -296,9 +189,9 @@ class Player {
}
// 恢复进度仅在明确指定且大于0时才恢复避免切换歌曲时意外恢复进度
if (seek && seek > 0) {
const duration = this.player.duration();
const duration = this.getDuration();
// 确保恢复的进度有效且距离歌曲结束大于2秒
if (duration && seek < duration - 2) {
if (duration && seek < duration - 2000) {
this.setSeek(seek);
}
}
@@ -323,8 +216,8 @@ class Player {
});
// 播放
this.player.on("play", () => {
if (currentSessionId !== this.playSessionId) return;
window.document.title = getPlayerInfo() || "SPlayer";
this.playerInterval.resume();
// 重置重试计数
try {
const current = getPlaySongData();
@@ -342,8 +235,8 @@ class Player {
});
// 暂停
this.player.on("pause", () => {
if (currentSessionId !== this.playSessionId) return;
if (!isElectron) window.document.title = "SPlayer";
this.playerInterval.pause();
// ipc
if (isElectron) {
window.electron.ipcRenderer.send("play-status-change", false);
@@ -352,10 +245,9 @@ class Player {
});
// 结束
this.player.on("end", () => {
if (currentSessionId !== this.playSessionId) return;
this.playerInterval.pause();
// statusStore.playStatus = false;
console.log("⏹️ song end:", playSongData);
// 检查是否需要在歌曲结束时执行自动关闭
const statusStore = useStatusStore();
if (
@@ -367,18 +259,15 @@ class Player {
this.executeAutoClose();
return;
}
this.nextOrPrev("next", true, true);
});
// 错误
this.player.on("loaderror", (sourceid, err: unknown) => {
if (currentSessionId !== this.playSessionId) return;
const code = typeof err === "number" ? err : undefined;
this.handlePlaybackError(code);
console.error("❌ song error:", sourceid, playSongData, err);
});
this.player.on("playerror", (sourceid, err: unknown) => {
if (currentSessionId !== this.playSessionId) return;
const code = typeof err === "number" ? err : undefined;
this.handlePlaybackError(code);
console.error("❌ song play error:", sourceid, playSongData, err);
@@ -543,13 +432,17 @@ class Player {
} else {
musicStore.playSong.cover = "/images/song.jpg?assest";
}
// 获取主色
runIdle(() => getCoverColor(musicStore.playSong.cover));
// 获取歌词数据
const { lyric, format } = await window.electron.ipcRenderer.invoke("get-music-lyric", path);
parseLocalLyric(lyric, format);
// 更新媒体会话
this.updateMediaSession();
// 获取元数据
const infoData: { format: IFormat } = await window.electron.ipcRenderer.invoke(
"get-music-metadata",
path,
);
// 更新音质
musicStore.playSong.quality = handleSongQuality(infoData.format.bitrate ?? 0);
// 获取主色
runIdle(() => getCoverColor(musicStore.playSong.cover));
} catch (error) {
window.$message.error("获取本地歌曲元信息失败");
console.error("Failed to parse local music info:", error);
@@ -559,8 +452,9 @@ class Player {
* 重置状态
*/
resetStatus() {
const statusStore = useStatusStore();
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 重置状态
statusStore.$patch({
currentTime: 0,
@@ -570,10 +464,11 @@ class Player {
playStatus: false,
playLoading: false,
});
musicStore.$patch({
playPlaylistId: 0,
playSong: {},
});
musicStore.playPlaylistId = 0;
musicStore.resetMusicData();
if (settingStore.showTaskbarProgress) {
window.electron.ipcRenderer.send("set-bar", "none");
}
}
/**
* 初始化播放器
@@ -586,7 +481,6 @@ class Player {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
const sessionId = this.newSession();
try {
// 获取播放数据
@@ -606,7 +500,6 @@ class Player {
// 本地歌曲
if (path) {
if (this.isStale(sessionId)) return;
try {
await this.createPlayer(`file://${path}`, autoPlay, seek);
await this.parseLocalMusicInfo(path);
@@ -615,7 +508,8 @@ class Player {
}
}
// 在线歌曲
else if (id && dataStore.playList.length) {
else if (id && (dataStore.playList.length || statusStore.personalFmMode)) {
// 播放地址
let playerUrl: string | null = null;
// 获取歌曲 URL 单独 try-catch
@@ -628,10 +522,13 @@ class Player {
if (cached && cached.id === songId && cached.url) {
playerUrl = cached.url;
statusStore.playUblock = cached.ublock;
statusStore.songQuality = cached.quality;
} else {
const canUnlock = isElectron && type !== "radio" && settingStore.useSongUnlock;
const { url: officialUrl, isTrial } = await getOnlineUrl(songId);
const { url: officialUrl, isTrial, quality } = await getOnlineUrl(songId);
// 更新音质
statusStore.songQuality = quality;
// 更新播放地址
if (officialUrl && !isTrial) {
playerUrl = officialUrl;
statusStore.playUblock = false;
@@ -655,20 +552,18 @@ class Player {
if (!playerUrl) {
window.$message.error("该歌曲暂无音源,跳至下一首");
this.switching = false;
await this.nextOrPrev("next");
return;
}
} catch (err) {
console.error("❌ 获取歌曲地址出错:", err);
window.$message.error("获取歌曲地址失败,跳至下一首");
this.switching = false;
await this.nextOrPrev("next");
return;
}
// 有有效 URL 才创建播放器
if (playerUrl && !this.isStale(sessionId)) {
if (playerUrl) {
try {
await this.createPlayer(playerUrl, autoPlay, seek);
} catch (err) {
@@ -679,10 +574,7 @@ class Player {
} catch (err) {
console.error("❌ 初始化音乐播放器出错:", err);
window.$message.error("播放遇到错误,尝试下一首");
this.switching = false;
await this.nextOrPrev("next");
} finally {
this.switching = false;
}
}
/**
@@ -722,7 +614,7 @@ class Player {
// 播放器未加载完成或不存在
if (!this.player || this.player.state() !== "loaded") {
if (changeStatus) statusStore.playStatus = false;
window.$message.warning("播放器未加载完成,请稍后重试");
return;
}
// 立即设置播放状态
@@ -755,12 +647,6 @@ class Player {
const dataStore = useDataStore();
const musicStore = useMusicStore();
try {
if (this.switching) {
console.log("🔄 Already switching, ignoring request");
return;
}
this.switching = true;
// 立即更新UI状态防止用户重复点击
statusStore.playLoading = true;
statusStore.playStatus = false;
@@ -769,15 +655,18 @@ class Player {
const { playList } = dataStore;
const { playSong } = musicStore;
const { playSongMode, playHeartbeatMode } = statusStore;
// 列表长度
const playListLength = playList.length;
// 播放列表是否为空
if (playListLength === 0) throw new Error("Play list is empty");
// 若为私人FM
if (statusStore.personalFmMode) {
await this.initPersonalFM(true);
return;
}
// 列表长度
const playListLength = playList.length;
// 播放列表是否为空
if (playListLength === 0) {
window.$message.error("播放列表为空,请添加歌曲");
return;
}
// 只有一首歌的特殊处理
if (playListLength === 1) {
statusStore.playLoading = false;
@@ -815,16 +704,12 @@ class Player {
// 重置播放进度(切换歌曲时必须重置)
statusStore.currentTime = 0;
statusStore.progress = 0;
// 暂停当前播放
await this.pause(false);
// 初始化播放器不传入seek参数确保从头开始播放
await this.initPlayer(play, 0);
} catch (error) {
console.error("Error in nextOrPrev:", error);
statusStore.playLoading = false;
throw error;
} finally {
this.switching = false;
}
}
/**
@@ -865,7 +750,7 @@ class Player {
const shuffled = shuffleArray(currentList);
await dataStore.setPlayList(shuffled);
if (currentSongId) {
const newIndex = shuffled.findIndex((s: any) => s?.id === currentSongId);
const newIndex = shuffled.findIndex((s) => s?.id === currentSongId);
if (newIndex !== -1) useStatusStore().playIndex = newIndex;
}
}
@@ -880,7 +765,7 @@ class Player {
const currentSongId = musicStore.playSong?.id;
await dataStore.setPlayList(original);
if (currentSongId) {
const origIndex = original.findIndex((s: any) => s?.id === currentSongId);
const origIndex = original.findIndex((s) => s?.id === currentSongId);
useStatusStore().playIndex = origIndex !== -1 ? origIndex : 0;
} else {
useStatusStore().playIndex = 0;
@@ -903,7 +788,7 @@ class Player {
}
/**
* 设置播放进度
* @param time 播放进度
* @param time 播放进度(单位:毫秒)
*/
setSeek(time: number) {
const statusStore = useStatusStore();
@@ -912,17 +797,28 @@ class Player {
console.warn("⚠️ Player not ready for seek");
return;
}
this.player.seek(time);
if (time < 0 || time > this.getDuration()) {
console.warn("⚠️ Invalid seek time", time);
time = Math.max(0, Math.min(time, this.getDuration()));
}
this.player.seek(time / 1000);
statusStore.currentTime = time;
}
/**
* 获取播放进度
* @returns 播放进度
* @returns 播放进度(单位:毫秒)
*/
getSeek(): number {
// 检查播放器状态
if (!this.player || this.player.state() !== "loaded") return 0;
return this.player.seek();
return Math.floor(this.player.seek() * 1000);
}
/**
* 获取播放时长
* @returns 播放时长(单位:毫秒)
*/
getDuration(): number {
return Math.floor(this.player.duration() * 1000);
}
/**
* 设置播放速率
@@ -1032,7 +928,6 @@ class Player {
// 查找索引(在处理后的列表中查找)
statusStore.playIndex = processedData.findIndex((item) => item.id === song.id);
// 播放
await this.pause(false);
await this.initPlayer();
}
} else {
@@ -1041,7 +936,6 @@ class Player {
? Math.floor(Math.random() * processedData.length)
: 0;
// 播放
await this.pause(false);
await this.initPlayer();
}
// 更改播放歌单
@@ -1081,11 +975,6 @@ class Player {
const dataStore = useDataStore();
const statusStore = useStatusStore();
try {
if (this.switching) {
console.log("🔄 Already switching, ignoring request");
return;
}
this.switching = true;
// 立即更新UI状态防止用户重复点击
statusStore.playLoading = true;
statusStore.playStatus = false;
@@ -1104,8 +993,6 @@ class Player {
statusStore.currentTime = 0;
statusStore.progress = 0;
statusStore.lyricIndex = -1;
// 暂停当前播放
await this.pause(false);
// 清理定时器,防止旧定时器继续运行
this.cleanupAllTimers();
// 清理并播放不传入seek参数确保从头开始播放
@@ -1114,8 +1001,6 @@ class Player {
console.error("Error in togglePlayIndex:", error);
statusStore.playLoading = false;
throw error;
} finally {
this.switching = false;
}
}
/**
@@ -1159,7 +1044,6 @@ class Player {
*/
async cleanPlayList() {
const dataStore = useDataStore();
const musicStore = useMusicStore();
const statusStore = useStatusStore();
// 停止播放
Howler.unload();
@@ -1172,9 +1056,9 @@ class Player {
personalFmMode: false,
playIndex: -1,
});
musicStore.resetMusicData();
dataStore.setPlayList([]);
window.$message.success("已清空播放列表");
// 清空播放列表及缓存
await dataStore.setPlayList([]);
await dataStore.clearOriginalPlayList();
}
/**
* 切换输出设备
@@ -1329,14 +1213,14 @@ class Player {
this.message = window.$message.loading("心动模式开启中", { duration: 0 });
// 获取所需数据
const playSongData = getPlaySongData();
const likeSongsList: any = await dataStore.getUserLikePlaylist();
const likeSongsList = await dataStore.getUserLikePlaylist();
// if (!playSongData || !likeSongsList) {
// throw new Error("获取播放数据或喜欢列表失败");
// }
const pid =
musicStore.playPlaylistId && musicStore.playPlaylistId !== 0
? musicStore.playPlaylistId
: likeSongsList?.detail?.id;
: (likeSongsList?.detail?.id ?? 0);
// 开启心动模式
const result = await heartRateList(playSongData?.id || 0, pid);
if (result.code === 200) {
@@ -1455,9 +1339,8 @@ class Player {
*/
private cleanupAllTimers() {
// 清理播放状态定时器
if (this.playerInterval) {
clearInterval(this.playerInterval);
this.playerInterval = undefined;
if (this.playerInterval.isActive.value) {
this.playerInterval.pause();
}
// 清理自动关闭定时器
if (this.autoCloseInterval) {
@@ -1480,4 +1363,15 @@ class Player {
}
}
export default new Player();
// export default new Player();
let _player: Player | null = null;
/**
* 获取播放器实例
* @returns Player
*/
export const usePlayer = (): Player => {
if (!_player) _player = new Player();
return _player;
};

13
src/utils/songManager.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* 歌曲解锁服务器
*/
export enum SongUnlockServer {
NETEASE = "netease",
BODIAN = "bodian",
// KUWO = "kuwo",
GEQUBAO = "gequbao",
}
class SongManager {}
export default new SongManager();

View File

@@ -75,25 +75,10 @@ export const formatCommentTime = (timestamp: number): string => {
*/
export const calculateProgress = (currentTime: number, duration: number): number => {
if (duration === 0) return 0;
const progress = (currentTime / duration) * 100;
return Math.min(Math.round(progress * 100) / 100, 100);
};
/**
* 根据进度和总时长反推当前时间
* @param {number} progress 进度百分比范围通常是0到100
* @param {number} duration 总时长,单位为秒
* @returns {number} 当前时间单位为秒精确到0.01秒
*/
export const calculateCurrentTime = (progress: number, duration: number): number => {
// 确保在有效范围内
progress = Math.min(Math.max(progress, 0), 100);
const currentTime = (progress / 100) * duration;
return Math.round(currentTime * 100) / 100;
};
/**
* 获取当前时间段的问候语
*/

View File

@@ -16,7 +16,7 @@ import { artistAllSongs } from "@/api/artist";
import { songDetail } from "@/api/song";
import { formatSongsList } from "@/utils/format";
import { debounce } from "lodash-es";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const props = defineProps<{
id: number;
@@ -26,6 +26,8 @@ const emit = defineEmits<{
scroll: [e: Event];
}>();
const player = usePlayer();
// 歌曲数据
const loading = ref<boolean>(true);
const hasMore = ref<boolean>(true);

View File

@@ -97,8 +97,9 @@ import { userCloud } from "@/api/cloud";
import { formatSongsList } from "@/utils/format";
import { fuzzySearch, renderIcon } from "@/utils/helper";
import { openBatchList } from "@/utils/modal";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const player = usePlayer();
const router = useRouter();
const dataStore = useDataStore();

View File

@@ -54,8 +54,9 @@ import { updateDailySongsData } from "@/utils/auth";
import { formatTimestamp } from "@/utils/time";
import { renderIcon } from "@/utils/helper";
import { openBatchList } from "@/utils/modal";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const player = usePlayer();
const musicStore = useMusicStore();
// 更新日期

View File

@@ -63,7 +63,7 @@
'lyric-line',
{
active: line.active,
'is-yrc': Boolean(lyricData?.yrcData?.length && line.line?.contents?.length),
'is-yrc': Boolean(lyricData?.yrcData?.length && line.line?.words?.length > 1),
},
]"
:style="{
@@ -72,9 +72,7 @@
:ref="(el) => line.active && (currentLineRef = el as HTMLElement)"
>
<!-- 逐字歌词渲染 -->
<template
v-if="lyricConfig.showYrc && lyricData?.yrcData?.length && line.line?.contents?.length"
>
<template v-if="lyricData?.yrcData?.length && line.line?.words?.length > 1">
<span
class="scroll-content"
:style="getScrollStyle(line)"
@@ -82,21 +80,25 @@
>
<span class="content">
<span
v-for="(text, textIndex) in line.line.contents"
v-for="(text, textIndex) in line.line.words"
:key="textIndex"
:class="{
'content-text': true,
'end-with-space': text.endsWithSpace,
'end-with-space': text.word.endsWith(' ') || text.startTime === 0,
}"
>
<span class="word" :style="{ color: lyricConfig.unplayedColor }">
{{ text.content }}
</span>
<span
class="filler"
:style="[{ color: lyricConfig.playedColor }, getYrcStyle(text, line.index)]"
class="word"
:style="[
{
backgroundImage: `linear-gradient(to right, ${lyricConfig.playedColor} 50%, ${lyricConfig.unplayedColor} 50%)`,
textShadow: 'none',
filter: `drop-shadow(0 0 1px ${lyricConfig.shadowColor}) drop-shadow(0 0 2px ${lyricConfig.shadowColor})`,
},
getYrcStyle(text, line.index),
]"
>
{{ text.content }}
{{ text.word }}
</span>
</span>
</span>
@@ -109,7 +111,7 @@
:style="getScrollStyle(line)"
:ref="(el) => line.active && (currentContentRef = el as HTMLElement)"
>
{{ line.line?.content }}
{{ line.line?.words?.[0]?.word || "" }}
</span>
</template>
</span>
@@ -121,16 +123,17 @@
</template>
<script setup lang="ts">
import { useRafFn } from "@vueuse/core";
import { LyricContentType, LyricType } from "@/types/main";
import { useRafFn, useTimeoutFn, useThrottleFn } from "@vueuse/core";
import { LyricLine, LyricWord } from "@applemusic-like-lyrics/lyric";
import { LyricConfig, LyricData, RenderLine } from "@/types/desktop-lyric";
import defaultDesktopLyricConfig from "@/assets/data/lyricConfig";
// 桌面歌词数据
const lyricData = reactive<LyricData>({
playName: "未知歌曲",
playName: "",
playStatus: false,
currentTime: 0,
lyricLoading: false,
songId: 0,
songOffset: 0,
lrcData: [],
@@ -164,7 +167,14 @@ const desktopLyricRef = ref<HTMLElement>();
// hover 状态控制
const isHovered = ref<boolean>(false);
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
const { start: startHoverTimer } = useTimeoutFn(
() => {
isHovered.value = false;
},
1000,
{ immediate: false },
);
/**
* 处理鼠标移动,更新 hover 状态
@@ -172,16 +182,7 @@ let hoverTimer: ReturnType<typeof setTimeout> | null = null;
const handleMouseMove = () => {
// 设置 hover 状态(锁定和非锁定状态都响应)
isHovered.value = true;
// 清除之前的定时器
if (hoverTimer) {
clearTimeout(hoverTimer);
hoverTimer = null;
}
// 设置新的定时器,延迟后移除 hover 状态
hoverTimer = setTimeout(() => {
isHovered.value = false;
hoverTimer = null;
}, 1000);
startHoverTimer();
};
/**
@@ -192,13 +193,13 @@ const handleMouseMove = () => {
* @param idx 当前行索引
* @returns 安全的结束时间(秒)
*/
const getSafeEndTime = (lyrics: LyricType[], idx: number) => {
const getSafeEndTime = (lyrics: LyricLine[], idx: number) => {
const cur = lyrics?.[idx];
const next = lyrics?.[idx + 1];
const curEnd = Number(cur?.endTime);
const curStart = Number(cur?.time);
const curStart = Number(cur?.startTime);
if (Number.isFinite(curEnd) && curEnd > curStart) return curEnd;
const nextStart = Number(next?.time);
const nextStart = Number(next?.startTime);
if (Number.isFinite(nextStart) && nextStart > curStart) return nextStart;
// 无有效结束参照:返回 0表示无时长不滚动
return 0;
@@ -210,65 +211,115 @@ const getSafeEndTime = (lyrics: LyricType[], idx: number) => {
*/
const renderLyricLines = computed<RenderLine[]>(() => {
const lyrics = lyricData?.yrcData?.length ? lyricData.yrcData : lyricData.lrcData;
if (!lyrics?.length) {
return [
{
line: { time: 0, endTime: 0, content: "纯音乐,请欣赏", contents: [] },
index: -1,
key: "placeholder",
active: true,
// 提示词占位
const placeholder = (word: string): RenderLine[] => [
{
line: {
startTime: 0,
endTime: 0,
words: [{ word, startTime: 0, endTime: 0, romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
];
index: -1,
key: "placeholder",
active: true,
},
];
// 无歌曲名且无歌词
if (!lyricData.playName && !lyrics?.length) {
return placeholder("SPlayer Desktop Lyric");
}
let idx = lyricData?.lyricIndex ?? -1;
// 显示歌名
// 加载中
if (lyricData.lyricLoading) return placeholder("歌词加载中...");
// 纯音乐
if (!lyrics?.length) return placeholder("纯音乐,请欣赏");
// 获取当前歌词索引
const idx = lyricData?.lyricIndex ?? -1;
// 索引小于 0显示歌曲名称
if (idx < 0) {
return [
{
line: { time: 0, endTime: 0, content: lyricData.playName ?? "未知歌曲", contents: [] },
index: -1,
key: "placeholder",
active: true,
},
];
const text = lyricData.playName ?? "未知歌曲";
return placeholder(text);
}
const current = lyrics[idx];
const next = lyrics[idx + 1];
if (!current) return [];
const safeEnd = getSafeEndTime(lyrics, idx);
// 有翻译:保留第二行显示翻译,第一行显示原文(逐字由 contents 驱动)
if (lyricConfig.showTran && current.tran && current.tran.trim().length > 0) {
if (
lyricConfig.showTran &&
current.translatedLyric &&
current.translatedLyric.trim().length > 0
) {
const lines: RenderLine[] = [
{ line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true },
{
line: { time: current.time, endTime: safeEnd, content: current.tran, contents: [] },
line: {
startTime: current.startTime,
endTime: safeEnd,
words: [
{
word: current.translatedLyric,
startTime: current.startTime,
endTime: safeEnd,
romanWord: "",
},
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
index: idx,
key: `${idx}:tran`,
active: false,
},
];
return lines.filter((l) => l.line?.content && l.line.content.trim().length > 0);
return lines.filter((l) => {
const s = (l.line?.words || [])
.map((w) => w.word)
.join("")
.trim();
return s.length > 0;
});
}
// 单行:仅当前句原文,高亮
if (!lyricConfig.isDoubleLine) {
return [
{ line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true },
].filter((l) => l.line?.content && l.line.content.trim().length > 0);
].filter((l) => {
const s = (l.line?.words || [])
.map((w) => w.word)
.join("")
.trim();
return s.length > 0;
});
}
// 双行交替:只高亮当前句所在行
const isEven = idx % 2 === 0;
if (isEven) {
const lines: RenderLine[] = [
{ line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true },
...(next ? [{ line: next, index: idx + 1, key: `${idx + 1}:next`, active: false }] : []),
];
return lines.filter((l) => l.line?.content && l.line.content.trim().length > 0);
return lines.filter((l) => {
const s = (l.line?.words || [])
.map((w) => w.word)
.join("")
.trim();
return s.length > 0;
});
}
const lines: RenderLine[] = [
...(next ? [{ line: next, index: idx + 1, key: `${idx + 1}:next`, active: false }] : []),
{ line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true },
];
return lines.filter((l) => l.line?.content && l.line.content.trim().length > 0);
return lines.filter((l) => {
const s = (l.line?.words || [])
.map((w) => w.word)
.join("")
.trim();
return s.length > 0;
});
});
/**
@@ -276,25 +327,23 @@ const renderLyricLines = computed<RenderLine[]>(() => {
* @param wordData 逐字歌词数据
* @param lyricIndex 歌词索引
*/
const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => {
const getYrcStyle = (wordData: LyricWord, lyricIndex: number) => {
const currentLine = lyricData.yrcData?.[lyricIndex];
if (!currentLine) return { WebkitMaskPositionX: "100%" };
const seek = playSeekMs.value / 1000; // 转为秒
if (!currentLine) return { backgroundPositionX: "100%" };
const seekSec = playSeekMs.value;
const startSec = currentLine.startTime || 0;
const endSec = currentLine.endTime || 0;
const isLineActive =
(seek >= currentLine.time && seek < currentLine.endTime) || lyricData.lyricIndex === lyricIndex;
(seekSec >= startSec && seekSec < endSec) || lyricData.lyricIndex === lyricIndex;
if (!isLineActive) {
// 已唱过保持填充状态(0%),未唱到保持未填充状态(100%)
const hasPlayed = seek >= wordData.time + wordData.duration;
return { WebkitMaskPositionX: hasPlayed ? "0%" : "100%" };
const hasPlayed = seekSec >= (wordData.endTime || 0);
return { backgroundPositionX: hasPlayed ? "0%" : "100%" };
}
// 激活状态:根据进度实时填充
const duration = wordData.duration || 0.001; // 避免除零
const progress = Math.max(Math.min((seek - wordData.time) / duration, 1), 0);
const durationSec = Math.max((wordData.endTime || 0) - (wordData.startTime || 0), 0.001);
const progress = Math.max(Math.min((seekSec - (wordData.startTime || 0)) / durationSec, 1), 0);
return {
transitionDuration: `0s, 0s, 0.35s`,
transitionDelay: `0ms`,
WebkitMaskPositionX: `${100 - progress * 100}%`,
backgroundPositionX: `${100 - progress * 100}%`,
};
};
@@ -323,8 +372,8 @@ const getScrollStyle = (line: RenderLine) => {
const overflow = Math.max(0, content.scrollWidth - container.clientWidth);
if (overflow <= 0) return { transform: "translateX(0px)" };
// 计算进度:毫秒锚点插值(`playSeekMs`),并以当前行的 `time` 与有效 `endTime` 计算区间
const seekSec = playSeekMs.value / 1000;
const start = Number(line.line.time ?? 0);
const seekSec = playSeekMs.value;
const start = Number(line.line.startTime ?? 0);
// 仅在滚动计算中提前 1 秒
const END_MARGIN_SEC = 1;
const endRaw = Number(line.line.endTime);
@@ -353,6 +402,11 @@ const dragState = reactive({
startWinY: 0,
winWidth: 0,
winHeight: 0,
// 缓存屏幕边界
minX: -99999,
minY: -99999,
maxX: 99999,
maxY: 99999,
});
/**
@@ -380,6 +434,14 @@ const startDrag = async (event: MouseEvent) => {
const { width, height } = await window.api.store.get("lyric");
const safeWidth = Number(width) > 0 ? Number(width) : 800;
const safeHeight = Number(height) > 0 ? Number(height) : 136;
// 如果开启了限制边界,在拖拽开始时预先获取一次屏幕范围
if (lyricConfig.limitBounds) {
const bounds = await window.electron.ipcRenderer.invoke("get-virtual-screen-bounds");
dragState.minX = bounds.minX ?? -99999;
dragState.minY = bounds.minY ?? -99999;
dragState.maxX = bounds.maxX ?? 99999;
dragState.maxY = bounds.maxY ?? 99999;
}
window.electron.ipcRenderer.send("toggle-fixed-max-size", {
width: safeWidth,
height: safeHeight,
@@ -400,19 +462,20 @@ const startDrag = async (event: MouseEvent) => {
* 桌面歌词拖动移动
* @param event 鼠标事件
*/
const onDocMouseMove = async (event: MouseEvent) => {
const onDocMouseMove = useThrottleFn((event: MouseEvent) => {
if (!dragState.isDragging || lyricConfig.isLock) return;
const screenX = event?.screenX ?? 0;
const screenY = event?.screenY ?? 0;
let newWinX = Math.round(dragState.startWinX + (screenX - dragState.startX));
let newWinY = Math.round(dragState.startWinY + (screenY - dragState.startY));
// 是否限制在屏幕边界(支持多屏)
// 是否限制在屏幕边界(支持多屏)- 使用缓存的边界数据同步计算
if (lyricConfig.limitBounds) {
const { minX, minY, maxX, maxY } = await window.electron.ipcRenderer.invoke(
"get-virtual-screen-bounds",
newWinX = Math.round(
Math.max(dragState.minX, Math.min(dragState.maxX - dragState.winWidth, newWinX)),
);
newWinY = Math.round(
Math.max(dragState.minY, Math.min(dragState.maxY - dragState.winHeight, newWinY)),
);
newWinX = Math.round(Math.max(minX as number, Math.min(maxX - dragState.winWidth, newWinX)));
newWinY = Math.round(Math.max(minY as number, Math.min(maxY - dragState.winHeight, newWinY)));
}
window.electron.ipcRenderer.send(
"move-window",
@@ -421,7 +484,7 @@ const onDocMouseMove = async (event: MouseEvent) => {
dragState.winWidth,
dragState.winHeight,
);
};
}, 16);
/**
* 桌面歌词拖动结束
@@ -549,7 +612,7 @@ onMounted(() => {
// 更新锚点:以传入的 currentTime + songOffset 建立毫秒级基准,并重置帧时间
if (typeof lyricData.currentTime === "number") {
const offset = Number(lyricData.songOffset ?? 0);
baseMs = Math.floor((lyricData.currentTime + offset) * 1000);
baseMs = Math.floor(lyricData.currentTime + offset);
anchorTick = performance.now();
}
// 按播放状态节能:暂停时暂停 RAF播放时恢复 RAF
@@ -594,11 +657,6 @@ onBeforeUnmount(() => {
// 解绑事件
document.removeEventListener("mousedown", onDocMouseDown);
document.removeEventListener("mousemove", handleMouseMove);
// 清理定时器
if (hoverTimer) {
clearTimeout(hoverTimer);
hoverTimer = null;
}
if (dragState.isDragging) onDocMouseUp();
});
</script>
@@ -683,6 +741,7 @@ onBeforeUnmount(() => {
.lyric-line {
width: 100%;
line-height: normal;
padding: 4px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -705,34 +764,14 @@ onBeforeUnmount(() => {
position: relative;
display: inline-block;
.word {
opacity: 1;
display: inline-block;
}
.filler {
opacity: 0;
position: absolute;
left: 0;
top: 0;
will-change: -webkit-mask-position-x, transform, opacity;
mask-image: linear-gradient(
to right,
rgb(0, 0, 0) 45.4545454545%,
rgba(0, 0, 0, 0) 54.5454545455%
);
mask-size: 220% 100%;
mask-repeat: no-repeat;
-webkit-mask-image: linear-gradient(
to right,
rgb(0, 0, 0) 45.4545454545%,
rgba(0, 0, 0, 0) 54.5454545455%
);
-webkit-mask-size: 220% 100%;
-webkit-mask-repeat: no-repeat;
transition:
opacity 0.3s,
filter 0.3s,
margin 0.3s,
padding 0.3s !important;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
background-size: 200% 100%;
background-repeat: no-repeat;
background-position-x: 100%;
will-change: background-position-x;
}
&.end-with-space {
margin-right: 5vh;
@@ -741,16 +780,6 @@ onBeforeUnmount(() => {
}
}
}
&.active {
.content-text {
.filler {
opacity: 1;
-webkit-mask-position-x: 0%;
transition-property: -webkit-mask-position-x, transform, opacity;
transition-timing-function: linear, ease, ease;
}
}
}
}
}
&.center {

View File

@@ -58,8 +58,9 @@
<script setup lang="ts">
import { useDataStore } from "@/stores";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const player = usePlayer();
const dataStore = useDataStore();
// 清空最近播放

View File

@@ -78,10 +78,15 @@ const likeData = computed(() => [
},
]);
onBeforeRouteUpdate((to) => {
if (to.matched[0].name !== "like") return;
likeType.value = to.name as string;
});
// 监听路由变化
watch(
() => router.currentRoute.value.name,
(val) => {
if (val && (val as string).startsWith("like-")) {
likeType.value = val as string;
}
},
);
</script>
<style lang="scss" scoped>

View File

@@ -34,17 +34,13 @@
</n-h2>
<n-collapse-transition :show="!listScrolling" class="collapse">
<!-- 简介 -->
<n-ellipsis
<n-text
v-if="albumDetailData.description"
:line-clamp="1"
:tooltip="{
trigger: 'click',
placement: 'bottom',
width: 'trigger',
}"
class="description text-hidden"
@click="openDescModal(albumDetailData.description, '专辑简介')"
>
{{ albumDetailData.description }}
</n-ellipsis>
</n-text>
<!-- 信息 -->
<n-flex class="meta">
<div class="item">
@@ -184,11 +180,12 @@ import { renderToolbar } from "@/utils/meta";
import { useDataStore, useStatusStore } from "@/stores";
import { debounce } from "lodash-es";
import { formatTimestamp } from "@/utils/time";
import { openJumpArtist } from "@/utils/modal";
import player from "@/utils/player";
import { openDescModal, openJumpArtist } from "@/utils/modal";
import { usePlayer } from "@/utils/player";
import { toLikeAlbum } from "@/utils/auth";
const router = useRouter();
const player = usePlayer();
const dataStore = useDataStore();
const statusStore = useStatusStore();
@@ -379,7 +376,7 @@ onMounted(() => {
border-radius: 8px;
height: 32px;
}
:deep(.n-ellipsis) {
.description {
margin-bottom: 8px;
cursor: pointer;
}

View File

@@ -37,17 +37,13 @@
<n-h2 class="name text-hidden"> 我喜欢的音乐 </n-h2>
<n-collapse-transition :show="!listScrolling" class="collapse">
<!-- 简介 -->
<n-ellipsis
<n-text
v-if="playlistDetailData.description"
:line-clamp="1"
:tooltip="{
trigger: 'click',
placement: 'bottom',
width: 'trigger',
}"
class="description text-hidden"
@click="openDescModal(playlistDetailData.description)"
>
{{ playlistDetailData.description }}
</n-ellipsis>
</n-text>
<!-- 信息 -->
<n-flex class="meta">
<div class="item">
@@ -58,7 +54,7 @@
<SvgIcon name="Update" :depth="3" />
<n-text>{{ formatTimestamp(playlistDetailData.updateTime) }}</n-text>
</div>
<div v-else-if="playlistDetailData.createTime" class="item">
<div v-if="playlistDetailData.createTime" class="item">
<SvgIcon name="Time" :depth="3" />
<n-text>{{ formatTimestamp(playlistDetailData.createTime) }}</n-text>
</div>
@@ -177,12 +173,13 @@ import { coverLoaded, formatNumber, fuzzySearch, renderIcon } from "@/utils/help
import { renderToolbar } from "@/utils/meta";
import { debounce, isObject, uniqBy } from "lodash-es";
import { useDataStore, useStatusStore } from "@/stores";
import { openBatchList, openUpdatePlaylist } from "@/utils/modal";
import { openBatchList, openDescModal, openUpdatePlaylist } from "@/utils/modal";
import { formatTimestamp } from "@/utils/time";
import { isLogin, updateUserLikePlaylist } from "@/utils/auth";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const router = useRouter();
const player = usePlayer();
const dataStore = useDataStore();
const statusStore = useStatusStore();
@@ -541,7 +538,7 @@ onMounted(async () => {
border-radius: 8px;
height: 32px;
}
:deep(.n-ellipsis) {
.description {
margin-bottom: 8px;
cursor: pointer;
}

View File

@@ -50,17 +50,13 @@
</n-h2>
<n-collapse-transition :show="!listScrolling" class="collapse">
<!-- 简介 -->
<n-ellipsis
<n-text
v-if="playlistDetailData.description"
:line-clamp="1"
:tooltip="{
trigger: 'click',
placement: 'bottom',
width: 'trigger',
}"
class="description text-hidden"
@click.stop="openDescModal(playlistDetailData.description)"
>
{{ playlistDetailData.description }}
</n-ellipsis>
</n-text>
<!-- 信息 -->
<n-flex class="meta">
<div class="item">
@@ -75,7 +71,7 @@
<SvgIcon name="Update" :depth="3" />
<n-text>{{ formatTimestamp(playlistDetailData.updateTime) }}</n-text>
</div>
<div v-else-if="playlistDetailData.createTime" class="item">
<div v-if="playlistDetailData.createTime" class="item">
<SvgIcon name="Time" :depth="3" />
<n-text>{{ formatTimestamp(playlistDetailData.createTime) }}</n-text>
</div>
@@ -228,11 +224,12 @@ import { renderToolbar } from "@/utils/meta";
import { isLogin, toLikePlaylist, updateUserLikePlaylist } from "@/utils/auth";
import { debounce } from "lodash-es";
import { useDataStore, useStatusStore } from "@/stores";
import { openBatchList, openUpdatePlaylist } from "@/utils/modal";
import { openBatchList, openDescModal, openUpdatePlaylist } from "@/utils/modal";
import { formatTimestamp } from "@/utils/time";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const router = useRouter();
const player = usePlayer();
const dataStore = useDataStore();
const statusStore = useStatusStore();
@@ -632,7 +629,7 @@ onMounted(() => getPlaylistDetail(playlistId.value));
border-radius: 8px;
height: 32px;
}
:deep(.n-ellipsis) {
.description {
margin-bottom: 8px;
cursor: pointer;
}

View File

@@ -32,17 +32,13 @@
</n-h2>
<n-collapse-transition :show="!listScrolling" class="collapse">
<!-- 简介 -->
<n-ellipsis
<n-text
v-if="radioDetailData.description"
:line-clamp="1"
:tooltip="{
trigger: 'click',
placement: 'bottom',
width: 'trigger',
}"
class="description text-hidden"
@click="openDescModal(radioDetailData.description, '节目简介')"
>
{{ radioDetailData.description }}
</n-ellipsis>
</n-text>
<!-- 信息 -->
<n-flex class="meta">
<div class="item">
@@ -183,11 +179,13 @@ import { renderToolbar } from "@/utils/meta";
import { debounce } from "lodash-es";
import { useDataStore, useStatusStore } from "@/stores";
import { radioAllProgram, radioDetail } from "@/api/radio";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
import { formatTimestamp } from "@/utils/time";
import { toSubRadio } from "@/utils/auth";
import { openDescModal } from "@/utils/modal";
const router = useRouter();
const player = usePlayer();
const dataStore = useDataStore();
const statusStore = useStatusStore();
@@ -457,7 +455,7 @@ onMounted(() => getRadioDetail(radioId.value));
border-radius: 8px;
height: 32px;
}
:deep(.n-ellipsis) {
.description {
margin-bottom: 8px;
cursor: pointer;
}

View File

@@ -120,7 +120,7 @@
<SvgIcon :size="20" name="Folder" />
</template>
<template #suffix>
<n-button quaternary @click="changeLocalLyricPath(index)">
<n-button quaternary @click="changeLocalMusicPath(index)">
<template #icon>
<SvgIcon :size="20" name="Delete" />
</template>
@@ -131,7 +131,7 @@
</n-list>
<template #footer>
<n-flex justify="center">
<n-button class="add-path" strong secondary @click="changeLocalLyricPath()">
<n-button class="add-path" strong secondary @click="changeLocalMusicPath()">
<template #icon>
<SvgIcon name="FolderPlus" />
</template>
@@ -149,11 +149,12 @@ import type { DropdownOption, MessageReactive } from "naive-ui";
import { useLocalStore, useSettingStore } from "@/stores";
import { formatSongsList } from "@/utils/format";
import { uniqBy, flattenDeep, debounce } from "lodash-es";
import { changeLocalLyricPath, fuzzySearch, renderIcon } from "@/utils/helper";
import { changeLocalMusicPath, fuzzySearch, renderIcon } from "@/utils/helper";
import { openBatchList } from "@/utils/modal";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
const router = useRouter();
const player = usePlayer();
const localStore = useLocalStore();
const settingStore = useSettingStore();

View File

@@ -1,5 +1,5 @@
<template>
<div :key="searchKeyword" class="search">
<div class="search">
<div class="title">
<n-text class="keyword">{{ searchKeyword }}</n-text>
<n-text depth="3">的相关搜索</n-text>
@@ -17,9 +17,20 @@
<RouterView v-slot="{ Component }">
<Transition :name="`router-${settingStore.routeAnimation}`" mode="out-in">
<KeepAlive v-if="settingStore.useKeepAlive">
<component :is="Component" :keyword="searchKeyword" class="router-view" />
<component
:is="Component"
:key="route.fullPath"
:keyword="searchKeyword"
class="router-view"
/>
</KeepAlive>
<component v-else :is="Component" :keyword="searchKeyword" class="router-view" />
<component
v-else
:is="Component"
:key="route.fullPath"
:keyword="searchKeyword"
class="router-view"
/>
</Transition>
</RouterView>
</div>
@@ -28,14 +39,15 @@
<script setup lang="ts">
import { useSettingStore } from "@/stores";
const route = useRoute();
const router = useRouter();
const settingStore = useSettingStore();
// 搜索关键词
const searchKeyword = computed(() => router.currentRoute.value.query.keyword as string);
const searchKeyword = computed(() => route.query.keyword as string);
// 搜索分类
const searchType = ref<string>((router.currentRoute.value?.name as string) || "search-songs");
const searchType = ref<string>("search-songs");
// Tabs 改变
const tabChange = (value: string) => {
@@ -47,10 +59,16 @@ const tabChange = (value: string) => {
});
};
onBeforeRouteUpdate((to) => {
if (to.matched[0].name !== "search") return;
searchType.value = to.name as string;
});
// 监听路由变化,同步 Tab 状态
watch(
() => route.name,
(name) => {
if (name && name.toString().startsWith("search-")) {
searchType.value = name as string;
}
},
{ immediate: true },
);
</script>
<style lang="scss" scoped>

View File

@@ -132,13 +132,14 @@ import { formatCommentList, formatCoverList } from "@/utils/format";
import { isArray, isEmpty } from "lodash-es";
import { formatNumber } from "@/utils/helper";
import { getComment } from "@/api/comment";
import player from "@/utils/player";
import { usePlayer } from "@/utils/player";
// Plyr
import Plyr from "plyr";
import "plyr/dist/plyr.css";
import { formatTimestamp } from "@/utils/time";
const router = useRouter();
const player = usePlayer();
const statusStore = useStatusStore();
// 是否激活