mirror of
https://github.com/imsyy/SPlayer.git
synced 2025-11-25 03:14:57 +08:00
Compare commits
43 Commits
v3.0.0-bet
...
6caf99da09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6caf99da09 | ||
|
|
ad27d1eaea | ||
|
|
8846d7f669 | ||
|
|
807b72ed9e | ||
|
|
d89be488e2 | ||
|
|
4bf986b763 | ||
|
|
ffb1fcc1ea | ||
|
|
ea822f91e8 | ||
|
|
5e260ffc0d | ||
|
|
e3dcde71e8 | ||
|
|
39c35e8a31 | ||
|
|
772f6552e7 | ||
|
|
c9f3553806 | ||
|
|
7de1355f18 | ||
|
|
392c64f06b | ||
|
|
0d66ced637 | ||
|
|
ae1ee71e75 | ||
|
|
84d9c999eb | ||
|
|
1a36fbf1d5 | ||
|
|
e0e62cd906 | ||
|
|
6d367f1fd5 | ||
|
|
2ed6acf934 | ||
|
|
236ee0a345 | ||
|
|
fe0f7a0f25 | ||
|
|
8529663ea5 | ||
|
|
100bae7488 | ||
|
|
91927b8d76 | ||
|
|
8be209837d | ||
|
|
9ae09b5711 | ||
|
|
3509d73ecc | ||
|
|
0bdb9c07b7 | ||
|
|
3218ab05f1 | ||
|
|
e40343f91f | ||
|
|
fcf1c235c3 | ||
|
|
ef0433c645 | ||
|
|
207f84fcd7 | ||
|
|
2985aa5977 | ||
|
|
ffd7aeff49 | ||
|
|
7f4a88daa1 | ||
|
|
91171465b2 | ||
|
|
1744a5a678 | ||
|
|
ce3d469547 | ||
|
|
7cc5b85fc5 |
56
.github/ISSUE_TEMPLATE/bug.yml
vendored
56
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -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: "请填写具体的复现步骤和遇到的问题"
|
||||
|
||||
588
auto-imports.d.ts
vendored
588
auto-imports.d.ts
vendored
@@ -6,300 +6,300 @@
|
||||
// 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 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
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
|
||||
8
components.d.ts
vendored
8
components.d.ts
vendored
@@ -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 */
|
||||
@@ -97,15 +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']
|
||||
|
||||
@@ -199,16 +199,52 @@ const initFileIpc = (): void => {
|
||||
// 读取本地歌词
|
||||
ipcMain.handle(
|
||||
"read-local-lyric",
|
||||
async (_, lyricDir: string, id: number, ext: string): Promise<string> => {
|
||||
const lyricPath = join(lyricDir, `${id}.${ext}`);
|
||||
async (_, lyricDirs: string[], id: number): Promise<{ lrc: string; ttml: string }> => {
|
||||
const result = { lrc: "", ttml: "" };
|
||||
|
||||
try {
|
||||
await access(lyricPath);
|
||||
const lyric = await readFile(lyricPath, "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, { cwd: 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, { cwd: 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;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ const initSystemIpc = (): void => {
|
||||
|
||||
// 退出应用
|
||||
ipcMain.on("quit-app", () => {
|
||||
app.exit();
|
||||
app.exit(0);
|
||||
app.quit();
|
||||
});
|
||||
|
||||
// 获取系统全部字体
|
||||
|
||||
@@ -177,8 +177,8 @@ const createTrayMenu = (win: BrowserWindow): MenuItemConstructorOptions[] => {
|
||||
label: "退出",
|
||||
icon: showIcon("power"),
|
||||
click: () => {
|
||||
win.close();
|
||||
// app.exit(0);
|
||||
// win.close();
|
||||
app.exit(0);
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ class MainWindow {
|
||||
});
|
||||
// 窗口显示时
|
||||
this.win?.on("show", () => {
|
||||
// this.mainWindow?.webContents.send("lyricsScroll");
|
||||
this.win?.webContents.send("lyricsScroll");
|
||||
});
|
||||
// 窗口获得焦点时
|
||||
this.win?.on("focus", () => {
|
||||
@@ -84,9 +84,7 @@ class MainWindow {
|
||||
// 窗口关闭
|
||||
this.win?.on("close", (event) => {
|
||||
event.preventDefault();
|
||||
this.win?.show();
|
||||
this.win?.focus();
|
||||
this.win?.webContents.send("win-will-close");
|
||||
this.win?.hide();
|
||||
});
|
||||
}
|
||||
/**
|
||||
|
||||
42
package.json
42
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "splayer",
|
||||
"productName": "SPlayer",
|
||||
"version": "3.0.0-beta.4",
|
||||
"version": "3.0.0-beta.5",
|
||||
"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",
|
||||
@@ -52,7 +52,7 @@
|
||||
"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,11 +66,11 @@
|
||||
"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",
|
||||
"vue-virt-list": "^1.6.1"
|
||||
},
|
||||
@@ -80,15 +80,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 +96,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 +122,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",
|
||||
|
||||
1504
pnpm-lock.yaml
generated
1504
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -217,6 +217,9 @@ const changeGlobalTheme = () => {
|
||||
railColor: toRGBA(primaryRGB, 0.2),
|
||||
railColorHover: toRGBA(primaryRGB, 0.3),
|
||||
},
|
||||
Popover: {
|
||||
color: `rgb(${surfaceContainerRGB})`,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -203,9 +203,6 @@ onMounted(() => {
|
||||
window.electron.ipcRenderer.on("win-state-change", (_event, value: boolean) => {
|
||||
isMax.value = value;
|
||||
});
|
||||
window.electron.ipcRenderer.on("win-will-close", () => {
|
||||
tryClose();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -234,10 +234,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 +264,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;
|
||||
};
|
||||
|
||||
// 列表触底
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
// 计算每个点的透明度
|
||||
|
||||
@@ -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" />
|
||||
@@ -96,6 +90,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 +122,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 };
|
||||
});
|
||||
|
||||
// 隐藏播放元素
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -36,8 +36,7 @@
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/vue";
|
||||
import { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
|
||||
import { msToS } from "@/utils/time";
|
||||
import { getLyricLanguage } from "@/utils/lyric";
|
||||
import { getLyricLanguage } from "@/utils/format";
|
||||
import player from "@/utils/player";
|
||||
import LyricMenu from "./LyricMenu.vue";
|
||||
|
||||
@@ -48,16 +47,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 +68,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 +80,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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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,11 +168,12 @@
|
||||
</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 { getLyricLanguage } from "@/utils/format";
|
||||
import { isElectron } from "@/utils/env";
|
||||
import LyricMenu from "./LyricMenu.vue";
|
||||
|
||||
const musicStore = useMusicStore();
|
||||
@@ -200,7 +205,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" });
|
||||
@@ -221,7 +226,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 值,避免多次访问响应式变量
|
||||
@@ -229,14 +234,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%",
|
||||
};
|
||||
@@ -248,24 +253,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 };
|
||||
}
|
||||
@@ -275,7 +284,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();
|
||||
};
|
||||
|
||||
@@ -291,6 +301,9 @@ onMounted(() => {
|
||||
nextTick().then(() => {
|
||||
lyricsScroll(statusStore.lyricIndex);
|
||||
});
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.on("lyricsScroll", () => lyricsScroll(statusStore.lyricIndex));
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -386,8 +399,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%,
|
||||
@@ -406,7 +419,7 @@ onBeforeUnmount(() => {
|
||||
opacity 0.3s,
|
||||
filter 0.3s,
|
||||
margin 0.3s,
|
||||
padding 0.3s !important;
|
||||
padding 0.3s;
|
||||
}
|
||||
&.end-with-space {
|
||||
margin-right: 12px;
|
||||
|
||||
@@ -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 {
|
||||
@@ -275,9 +275,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>
|
||||
|
||||
|
||||
@@ -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,7 +92,7 @@
|
||||
|
||||
<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";
|
||||
|
||||
@@ -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,74 @@
|
||||
}}
|
||||
</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">{{ 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 +104,8 @@ import { debounce, isObject } from "lodash-es";
|
||||
defineProps<{
|
||||
center?: boolean;
|
||||
theme?: string;
|
||||
// 少量数据模式
|
||||
light?: boolean;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
@@ -91,6 +113,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 +165,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 +208,6 @@ const jumpPage = debounce(
|
||||
}
|
||||
.album,
|
||||
.dj {
|
||||
margin-top: 2px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -196,6 +225,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 +258,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;
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStatusStore } from "@/stores";
|
||||
import { msToTime } from "@/utils/time";
|
||||
import player from "@/utils/player";
|
||||
import { secondsToTime } from "@/utils/time";
|
||||
|
||||
withDefaults(defineProps<{ showTooltip?: boolean }>(), { showTooltip: true });
|
||||
|
||||
@@ -66,7 +66,7 @@ const endDrag = () => {
|
||||
|
||||
// 格式化提示
|
||||
const formatTooltip = (value: number) => {
|
||||
return `${secondsToTime(value)} / ${secondsToTime(statusStore.duration)}`;
|
||||
return `${msToTime(value)} / ${msToTime(statusStore.duration)}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -50,10 +50,12 @@
|
||||
<n-flex justify="space-between">
|
||||
<div class="label">
|
||||
<n-text class="name">本地歌词覆盖在线歌词</n-text>
|
||||
<n-text class="tip" :depth="3"
|
||||
>可在这些文件夹内覆盖在线歌曲的歌词,将歌词文件命名为 `歌曲ID.后缀名` 即可,支持 LRC
|
||||
和 TTML 格式</n-text
|
||||
>
|
||||
<n-text class="tip" :depth="3">
|
||||
可在这些文件夹及其子文件夹内覆盖在线歌曲的歌词 <br />
|
||||
将歌词文件命名为 `歌曲ID.后缀名` 或者 `任意前缀.歌曲ID.后缀名` 即可 <br />
|
||||
支持 .lrc 和 .ttml 格式 <br />
|
||||
(提示:可以在前缀加上歌名等信息,也可以利用子文件夹分类管理)
|
||||
</n-text>
|
||||
</div>
|
||||
<n-button strong secondary @click="changeLocalLyricPath()">
|
||||
<template #icon>
|
||||
|
||||
@@ -244,6 +244,24 @@
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.lyricsBlur" class="set" :round="false" />
|
||||
</n-card>
|
||||
</div>
|
||||
<div class="set-list">
|
||||
<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-text>
|
||||
<n-text class="tip" :depth="3">
|
||||
是否从 AMLL TTML DB 获取歌词(如有),TTML
|
||||
歌词支持逐字、翻译、音译等功能,将会在下一首歌生效
|
||||
</n-text>
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.enableTTMLLyric" class="set" :round="false" />
|
||||
</n-card>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">启用歌词排除</n-text>
|
||||
@@ -307,17 +325,12 @@
|
||||
是否使用物理弹簧算法实现歌词动画效果,需要高性能设备
|
||||
</n-text>
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.useAMSpring" class="set" :round="false" />
|
||||
</n-card>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">启用在线 TTML 歌词</n-text>
|
||||
<n-text class="tip" :depth="3">
|
||||
是否从 AMLL TTML DB 获取歌词(如有),TTML
|
||||
歌词支持逐字、翻译、音译等功能,将会在下一首歌生效
|
||||
</n-text>
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.enableTTMLLyric" class="set" :round="false" />
|
||||
<n-switch
|
||||
v-model:value="settingStore.useAMSpring"
|
||||
class="set"
|
||||
:round="false"
|
||||
:disabled="!settingStore.useAMLyrics"
|
||||
/>
|
||||
</n-card>
|
||||
</div>
|
||||
<div v-if="isElectron" class="set-list">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -155,6 +155,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>
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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: {
|
||||
@@ -88,32 +81,23 @@ export const useMusicStore = defineStore("music", {
|
||||
// 恢复默认音乐数据
|
||||
resetMusicData() {
|
||||
this.playSong = { ...defaultMusicData };
|
||||
this.songLyric = {
|
||||
lrcData: [],
|
||||
yrcData: [],
|
||||
lrcAMData: [],
|
||||
yrcAMData: [],
|
||||
};
|
||||
this.songLyric = { lrcData: [], yrcData: [] };
|
||||
},
|
||||
/**
|
||||
* 设置/更新歌曲歌词数据
|
||||
* @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,
|
||||
};
|
||||
}
|
||||
// 更新歌词窗口数据
|
||||
|
||||
@@ -169,6 +169,8 @@ export interface SettingState {
|
||||
excludeRegexes: string[];
|
||||
/** 显示默认本地路径 */
|
||||
showDefaultLocalPath: boolean;
|
||||
/** 展示当前歌曲歌词状态信息 */
|
||||
showPlayMeta: boolean;
|
||||
}
|
||||
|
||||
export const useSettingStore = defineStore("setting", {
|
||||
@@ -247,6 +249,7 @@ export const useSettingStore = defineStore("setting", {
|
||||
proxyPort: 80,
|
||||
useRealIP: false,
|
||||
realIP: "",
|
||||
showPlayMeta: false,
|
||||
}),
|
||||
getters: {
|
||||
/**
|
||||
|
||||
@@ -175,16 +175,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 +202,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 {
|
||||
|
||||
109
src/types/amll.d.ts
vendored
109
src/types/amll.d.ts
vendored
@@ -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[];
|
||||
}
|
||||
8
src/types/desktop-lyric.d.ts
vendored
8
src/types/desktop-lyric.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import { LyricType } from "@/types/main";
|
||||
import { type LyricLine } from "@applemusic-like-lyrics/lyric";
|
||||
|
||||
/** 桌面歌词数据 */
|
||||
export interface LyricData {
|
||||
@@ -13,8 +13,8 @@ export interface LyricData {
|
||||
/** 当前歌曲的时间偏移(秒,正负均可) */
|
||||
songOffset?: number;
|
||||
/** 歌词数据 */
|
||||
lrcData?: LyricType[];
|
||||
yrcData?: LyricType[];
|
||||
lrcData?: LyricLine[];
|
||||
yrcData?: LyricLine[];
|
||||
/** 歌词播放索引 */
|
||||
lyricIndex?: number;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export interface LyricConfig {
|
||||
*/
|
||||
export interface RenderLine {
|
||||
/** 当前整行歌词数据(用于逐字渲染) */
|
||||
line: LyricType;
|
||||
line: LyricLine;
|
||||
/** 当前行在歌词数组中的索引 */
|
||||
index: number;
|
||||
/** 唯一键 */
|
||||
|
||||
9
src/types/lyric.d.ts
vendored
Normal file
9
src/types/lyric.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { type LyricLine } from "@applemusic-like-lyrics/lyric";
|
||||
|
||||
/**
|
||||
* 歌词数据类型
|
||||
*/
|
||||
export interface SongLyric {
|
||||
lrcData: LyricLine[];
|
||||
yrcData: LyricLine[];
|
||||
}
|
||||
10
src/types/main.d.ts
vendored
10
src/types/main.d.ts
vendored
@@ -128,11 +128,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 +158,9 @@ export type LyricType = {
|
||||
isBG?: boolean;
|
||||
/** 是否为对唱歌词 */
|
||||
isDuet?: boolean;
|
||||
/** 歌词内容 */
|
||||
content: string;
|
||||
/** 歌词内容数组 */
|
||||
contents: LyricContentType[];
|
||||
};
|
||||
|
||||
|
||||
@@ -267,3 +267,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";
|
||||
};
|
||||
|
||||
@@ -367,7 +367,7 @@ export const changeLocalMusicPath = changeLocalPath(
|
||||
*/
|
||||
export const changeLocalLyricPath = changeLocalPath(
|
||||
"localLyricPath",
|
||||
false,
|
||||
true,
|
||||
"Error changing local lyric path",
|
||||
"更改本地歌词文件夹出错,请重试",
|
||||
false,
|
||||
|
||||
@@ -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 = words[0].startTime;
|
||||
const 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];
|
||||
};
|
||||
408
src/utils/lyricManager.ts
Normal file
408
src/utils/lyricManager.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
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 () => {
|
||||
try {
|
||||
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;
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
// 处理 LRC 歌词
|
||||
const adoptLRC = async () => {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
// 设置 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 id 歌曲 ID
|
||||
* @param path 本地歌词路径(可选)
|
||||
*/
|
||||
public async handleLyric(id: number, path?: string) {
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
// 标记当前歌词请求(避免旧请求覆盖新请求)
|
||||
const req = ++this.lyricReqSeq;
|
||||
this.activeLyricReq = req;
|
||||
try {
|
||||
// 歌词加载状态
|
||||
statusStore.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);
|
||||
}
|
||||
// 仅当请求未过期时才更新
|
||||
if (this.activeLyricReq === req) {
|
||||
// 如果只有逐字歌词
|
||||
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);
|
||||
console.log("最终歌词数据", lyricData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 处理歌词失败:", error);
|
||||
// 重置歌词
|
||||
this.resetSongLyric();
|
||||
} finally {
|
||||
// 只有当这个请求是最新的时候,才关闭加载状态
|
||||
if (req === this.activeLyricReq) {
|
||||
statusStore.lyricLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 计算歌词索引
|
||||
* - 普通歌词(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();
|
||||
@@ -1,170 +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);
|
||||
const ttmlPromise = settingStore.enableTTMLLyric ? getLyric("ttml", songLyricTTML) : null;
|
||||
|
||||
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 };
|
||||
};
|
||||
@@ -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";
|
||||
@@ -84,22 +84,24 @@ 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 results = await Promise.allSettled([
|
||||
unlockSongUrl(songId, keyWord, "bodian"),
|
||||
unlockSongUrl(songId, keyWord, "netease"),
|
||||
]);
|
||||
const promises = servers.map(server => unlockSongUrl(songId, keyWord, server));
|
||||
const results = await Promise.allSettled(promises);
|
||||
// 解析结果
|
||||
const [neteaseRes, kuwoRes] = results;
|
||||
if (
|
||||
neteaseRes.status === "fulfilled" &&
|
||||
neteaseRes.value.code === 200 &&
|
||||
neteaseRes.value.url
|
||||
) {
|
||||
return neteaseRes.value.url;
|
||||
}
|
||||
if (kuwoRes.status === "fulfilled" && kuwoRes.value.code === 200 && kuwoRes.value.url) {
|
||||
return kuwoRes.value.url;
|
||||
for (const result of results) {
|
||||
if (
|
||||
result.status === "fulfilled" &&
|
||||
result.value.code === 200 &&
|
||||
result.value.url
|
||||
) {
|
||||
return result.value.url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 { calculateProgress } from "./time";
|
||||
import { shuffleArray, runIdle } from "./helper";
|
||||
import { heartRateList } from "@/api/playlist";
|
||||
@@ -19,8 +18,9 @@ import {
|
||||
getUnlockSongUrl,
|
||||
} 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";
|
||||
|
||||
// 播放器核心
|
||||
@@ -106,11 +106,11 @@ class Player {
|
||||
}
|
||||
if (!this.player.playing()) return;
|
||||
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 });
|
||||
// 客户端事件
|
||||
@@ -245,9 +245,11 @@ class Player {
|
||||
if (!settingStore.showSpectrums) this.toggleOutputDevice();
|
||||
// 自动播放
|
||||
if (autoPlay) await this.play();
|
||||
// 获取歌曲附加信息 - 非电台和本地
|
||||
if (type !== "radio" && !path) getLyricData(id);
|
||||
else resetSongLyric();
|
||||
// // 获取歌曲附加信息 - 非电台和本地
|
||||
// if (type !== "radio" && !path) getLyricData(id);
|
||||
// else resetSongLyric();
|
||||
// 获取歌词数据
|
||||
lyricManager.handleLyric(id, path);
|
||||
// 定时获取状态
|
||||
if (!this.playerInterval) this.handlePlayStatus();
|
||||
// 新增播放历史
|
||||
@@ -296,9 +298,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);
|
||||
}
|
||||
}
|
||||
@@ -505,6 +507,7 @@ class Player {
|
||||
}
|
||||
// 超过次数:切到下一首或清空
|
||||
this.retryInfo.count = 0;
|
||||
this.switching = false;
|
||||
if (dataStore.playList.length > 1) {
|
||||
window.$message.error("当前歌曲播放失败,已跳至下一首");
|
||||
await this.nextOrPrev("next");
|
||||
@@ -546,8 +549,8 @@ class Player {
|
||||
// 获取主色
|
||||
runIdle(() => getCoverColor(musicStore.playSong.cover));
|
||||
// 获取歌词数据
|
||||
const { lyric, format } = await window.electron.ipcRenderer.invoke("get-music-lyric", path);
|
||||
parseLocalLyric(lyric, format);
|
||||
// const { lyric, format } = await window.electron.ipcRenderer.invoke("get-music-lyric", path);
|
||||
// parseLocalLyric(lyric, format);
|
||||
// 更新媒体会话
|
||||
this.updateMediaSession();
|
||||
} catch (error) {
|
||||
@@ -865,7 +868,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 +883,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 +906,7 @@ class Player {
|
||||
}
|
||||
/**
|
||||
* 设置播放进度
|
||||
* @param time 播放进度
|
||||
* @param time 播放进度(单位:毫秒)
|
||||
*/
|
||||
setSeek(time: number) {
|
||||
const statusStore = useStatusStore();
|
||||
@@ -912,17 +915,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);
|
||||
}
|
||||
/**
|
||||
* 设置播放速率
|
||||
@@ -1329,14 +1343,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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前时间段的问候语
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<n-config-provider :theme="null">
|
||||
<div ref="desktopLyricRef" :class="['desktop-lyric', { locked: lyricConfig.isLock }]">
|
||||
<div
|
||||
ref="desktopLyricRef"
|
||||
:class="['desktop-lyric', { locked: lyricConfig.isLock, hovered: isHovered }]"
|
||||
>
|
||||
<div class="header" align="center" justify="space-between">
|
||||
<n-flex :wrap="false" align="center" justify="flex-start" size="small" @pointerdown.stop>
|
||||
<div class="menu-btn" title="返回应用" @click.stop="sendToMain('win-show')">
|
||||
@@ -60,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="{
|
||||
@@ -70,7 +73,7 @@
|
||||
>
|
||||
<!-- 逐字歌词渲染 -->
|
||||
<template
|
||||
v-if="lyricConfig.showYrc && lyricData?.yrcData?.length && line.line?.contents?.length"
|
||||
v-if="lyricConfig.showYrc && lyricData?.yrcData?.length && line.line?.words?.length > 1"
|
||||
>
|
||||
<span
|
||||
class="scroll-content"
|
||||
@@ -79,21 +82,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>
|
||||
@@ -106,7 +113,7 @@
|
||||
:style="getScrollStyle(line)"
|
||||
:ref="(el) => line.active && (currentContentRef = el as HTMLElement)"
|
||||
>
|
||||
{{ line.line?.content }}
|
||||
{{ line.line?.words?.[0]?.word || "" }}
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
@@ -118,8 +125,8 @@
|
||||
</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";
|
||||
|
||||
@@ -159,6 +166,26 @@ const lyricConfig = reactive<LyricConfig>({
|
||||
// 桌面歌词元素
|
||||
const desktopLyricRef = ref<HTMLElement>();
|
||||
|
||||
// hover 状态控制
|
||||
const isHovered = ref<boolean>(false);
|
||||
|
||||
const { start: startHoverTimer } = useTimeoutFn(
|
||||
() => {
|
||||
isHovered.value = false;
|
||||
},
|
||||
1000,
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* 处理鼠标移动,更新 hover 状态
|
||||
*/
|
||||
const handleMouseMove = () => {
|
||||
// 设置 hover 状态(锁定和非锁定状态都响应)
|
||||
isHovered.value = true;
|
||||
startHoverTimer();
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算安全的结束时间
|
||||
* - 优先使用当前行的 `endTime`
|
||||
@@ -167,13 +194,13 @@ const desktopLyricRef = ref<HTMLElement>();
|
||||
* @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;
|
||||
@@ -188,19 +215,35 @@ const renderLyricLines = computed<RenderLine[]>(() => {
|
||||
if (!lyrics?.length) {
|
||||
return [
|
||||
{
|
||||
line: { time: 0, endTime: 0, content: "纯音乐,请欣赏", contents: [] },
|
||||
line: {
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
words: [{ word: "纯音乐,请欣赏", startTime: 0, endTime: 0, romanWord: "" }],
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
},
|
||||
index: -1,
|
||||
key: "placeholder",
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
let idx = lyricData?.lyricIndex ?? -1;
|
||||
// 显示歌名
|
||||
const idx = lyricData?.lyricIndex ?? -1;
|
||||
if (idx < 0) {
|
||||
const text = lyricData.playName ?? "未知歌曲";
|
||||
return [
|
||||
{
|
||||
line: { time: 0, endTime: 0, content: lyricData.playName ?? "未知歌曲", contents: [] },
|
||||
line: {
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
words: [{ word: text, startTime: 0, endTime: 0, romanWord: "" }],
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
},
|
||||
index: -1,
|
||||
key: "placeholder",
|
||||
active: true,
|
||||
@@ -211,39 +254,79 @@ const renderLyricLines = computed<RenderLine[]>(() => {
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -251,25 +334,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}%`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -298,8 +379,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);
|
||||
@@ -328,13 +409,18 @@ const dragState = reactive({
|
||||
startWinY: 0,
|
||||
winWidth: 0,
|
||||
winHeight: 0,
|
||||
// 缓存屏幕边界
|
||||
minX: -99999,
|
||||
minY: -99999,
|
||||
maxX: 99999,
|
||||
maxY: 99999,
|
||||
});
|
||||
|
||||
/**
|
||||
* 桌面歌词拖动开始
|
||||
* @param event 指针事件
|
||||
* @param event 鼠标事件
|
||||
*/
|
||||
const onDocPointerDown = async (event: PointerEvent) => {
|
||||
const onDocMouseDown = async (event: MouseEvent) => {
|
||||
if (lyricConfig.isLock) return;
|
||||
// 仅左键触发
|
||||
if (event.button !== 0) return;
|
||||
@@ -347,41 +433,56 @@ const onDocPointerDown = async (event: PointerEvent) => {
|
||||
|
||||
/**
|
||||
* 桌面歌词拖动开始
|
||||
* @param event 指针事件
|
||||
* @param event 鼠标事件
|
||||
*/
|
||||
const startDrag = async (event: PointerEvent) => {
|
||||
const startDrag = async (event: MouseEvent) => {
|
||||
dragState.isDragging = true;
|
||||
const { x, y } = await window.electron.ipcRenderer.invoke("get-window-bounds");
|
||||
const { width, height } = await window.api.store.get("lyric");
|
||||
window.electron.ipcRenderer.send("toggle-fixed-max-size", { width, height, fixed: true });
|
||||
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,
|
||||
fixed: true,
|
||||
});
|
||||
dragState.startX = event?.screenX ?? 0;
|
||||
dragState.startY = event?.screenY ?? 0;
|
||||
dragState.startWinX = x;
|
||||
dragState.startWinY = y;
|
||||
dragState.winWidth = width ?? 0;
|
||||
dragState.winHeight = height ?? 0;
|
||||
document.addEventListener("pointermove", onDocPointerMove, { capture: true });
|
||||
document.addEventListener("pointerup", onDocPointerUp, { capture: true });
|
||||
dragState.winWidth = safeWidth;
|
||||
dragState.winHeight = safeHeight;
|
||||
document.addEventListener("mousemove", onDocMouseMove);
|
||||
document.addEventListener("mouseup", onDocMouseUp);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
/**
|
||||
* 桌面歌词拖动移动
|
||||
* @param event 指针事件
|
||||
* @param event 鼠标事件
|
||||
*/
|
||||
const onDocPointerMove = async (event: PointerEvent) => {
|
||||
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",
|
||||
@@ -390,18 +491,18 @@ const onDocPointerMove = async (event: PointerEvent) => {
|
||||
dragState.winWidth,
|
||||
dragState.winHeight,
|
||||
);
|
||||
};
|
||||
}, 16);
|
||||
|
||||
/**
|
||||
* 桌面歌词拖动结束
|
||||
*/
|
||||
const onDocPointerUp = () => {
|
||||
const onDocMouseUp = () => {
|
||||
if (!dragState.isDragging) return;
|
||||
// 关闭拖拽状态
|
||||
dragState.isDragging = false;
|
||||
// 移除全局监听
|
||||
document.removeEventListener("pointermove", onDocPointerMove, { capture: true });
|
||||
document.removeEventListener("pointerup", onDocPointerUp, { capture: true });
|
||||
document.removeEventListener("mousemove", onDocMouseMove);
|
||||
document.removeEventListener("mouseup", onDocMouseUp);
|
||||
requestAnimationFrame(() => {
|
||||
// 恢复拖拽前宽高
|
||||
window.electron.ipcRenderer.send("update-lyric-size", dragState.winWidth, dragState.winHeight);
|
||||
@@ -518,7 +619,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
|
||||
@@ -552,15 +653,18 @@ onMounted(() => {
|
||||
pauseSeek();
|
||||
}
|
||||
// 拖拽入口
|
||||
document.addEventListener("pointerdown", onDocPointerDown, { capture: true });
|
||||
document.addEventListener("mousedown", onDocMouseDown);
|
||||
// 监听鼠标移动,控制 hover 状态
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 关闭 RAF
|
||||
pauseSeek();
|
||||
// 解绑事件
|
||||
document.removeEventListener("pointerdown", onDocPointerDown, { capture: true });
|
||||
if (dragState.isDragging) onDocPointerUp();
|
||||
document.removeEventListener("mousedown", onDocMouseDown);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
if (dragState.isDragging) onDocMouseUp();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -620,6 +724,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
&.lock-btn {
|
||||
pointer-events: auto;
|
||||
.n-icon {
|
||||
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.8));
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
@@ -641,6 +748,7 @@ onBeforeUnmount(() => {
|
||||
.lyric-line {
|
||||
width: 100%;
|
||||
line-height: normal;
|
||||
padding: 4px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -663,34 +771,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;
|
||||
@@ -699,16 +787,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 {
|
||||
@@ -746,7 +824,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
&.hovered {
|
||||
&:not(.locked) {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
.song-name,
|
||||
@@ -762,7 +840,7 @@ onBeforeUnmount(() => {
|
||||
.lyric-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
&:hover {
|
||||
&.hovered {
|
||||
.lock-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user