mirror of
https://github.com/imsyy/SPlayer.git
synced 2025-11-25 11:29:26 +08:00
Compare commits
57 Commits
v3.0.0-alp
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146af3aeba | ||
|
|
b2ddb9f4e2 | ||
|
|
201186bab2 | ||
|
|
bfcd59daca | ||
|
|
d5c3843c3f | ||
|
|
d3f307eac5 | ||
|
|
675a52b8d1 | ||
|
|
aee90e9c4e | ||
|
|
eb39b81d8d | ||
|
|
436df47104 | ||
|
|
e04e5e34c6 | ||
|
|
b57d685c03 | ||
|
|
6684172592 | ||
|
|
0257e74ff0 | ||
|
|
16c8865651 | ||
|
|
1a61aa2458 | ||
|
|
e543f07d8e | ||
|
|
96a0495a88 | ||
|
|
02befcd8a4 | ||
|
|
15b500806a | ||
|
|
9accf5d27d | ||
|
|
191ab29a44 | ||
|
|
a4d4cd5f70 | ||
|
|
3b07f7346f | ||
|
|
1edceeebdd | ||
|
|
fbf261f80b | ||
|
|
432fa18299 | ||
|
|
b2dcec840b | ||
|
|
6f93d65f02 | ||
|
|
852b901353 | ||
|
|
71a282157d | ||
|
|
49f462cdf5 | ||
|
|
94c0ca70e1 | ||
|
|
813637762c | ||
|
|
eb39b85a8b | ||
|
|
201fd8d687 | ||
|
|
02116d8f0f | ||
|
|
551a190edf | ||
|
|
f5015a4028 | ||
|
|
ea55616bd6 | ||
|
|
2b8eb93404 | ||
|
|
18113d94e9 | ||
|
|
a8f01d5728 | ||
|
|
3a6f8e7462 | ||
|
|
57325bfc46 | ||
|
|
a3e8931460 | ||
|
|
2e0f448f66 | ||
|
|
44cfbbf4e0 | ||
|
|
963343c39e | ||
|
|
a697799f48 | ||
|
|
7c59cc2ccb | ||
|
|
11acbcf7eb | ||
|
|
c35ede2158 | ||
|
|
dc480459eb | ||
|
|
c100bfed8d | ||
|
|
2b48713565 | ||
|
|
528f9b0aa6 |
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.gitignore
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
@@ -3,11 +3,14 @@
|
||||
"Component": true,
|
||||
"ComponentPublicInstance": true,
|
||||
"ComputedRef": true,
|
||||
"DirectiveBinding": true,
|
||||
"EffectScope": true,
|
||||
"ExtractDefaultPropTypes": true,
|
||||
"ExtractPropTypes": true,
|
||||
"ExtractPublicPropTypes": true,
|
||||
"InjectionKey": true,
|
||||
"MaybeRef": true,
|
||||
"MaybeRefOrGetter": true,
|
||||
"PropType": true,
|
||||
"Ref": true,
|
||||
"VNode": true,
|
||||
@@ -71,6 +74,7 @@
|
||||
"onStartTyping": true,
|
||||
"onUnmounted": true,
|
||||
"onUpdated": true,
|
||||
"onWatcherCleanup": true,
|
||||
"pausableWatch": true,
|
||||
"provide": true,
|
||||
"provideLocal": true,
|
||||
@@ -180,6 +184,7 @@
|
||||
"useFullscreen": true,
|
||||
"useGamepad": true,
|
||||
"useGeolocation": true,
|
||||
"useId": true,
|
||||
"useIdle": true,
|
||||
"useImage": true,
|
||||
"useInfiniteScroll": true,
|
||||
@@ -198,6 +203,7 @@
|
||||
"useMemoize": true,
|
||||
"useMemory": true,
|
||||
"useMessage": true,
|
||||
"useModel": true,
|
||||
"useMounted": true,
|
||||
"useMouse": true,
|
||||
"useMouseInElement": true,
|
||||
@@ -246,6 +252,7 @@
|
||||
"useStyleTag": true,
|
||||
"useSupported": true,
|
||||
"useSwipe": true,
|
||||
"useTemplateRef": true,
|
||||
"useTemplateRefsList": true,
|
||||
"useTextDirection": true,
|
||||
"useTextSelection": true,
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:vue/vue3-essential",
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
files: [".eslintrc.{js,cjs}"],
|
||||
parserOptions: {
|
||||
sourceType: "script",
|
||||
},
|
||||
},
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
parser: "@typescript-eslint/parser",
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["@typescript-eslint", "vue"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
},
|
||||
global: {
|
||||
h: "readonly",
|
||||
ref: "readonly",
|
||||
computed: "readonly",
|
||||
watch: "readonly",
|
||||
watchEffect: "readonly",
|
||||
onBeforeMount: "readonly",
|
||||
onBeforeUnmount: "readonly",
|
||||
onBeforeUpdate: "readonly",
|
||||
reactive: "readonly",
|
||||
onMounted: "readonly",
|
||||
onUnmounted: "readonly",
|
||||
onActivated: "readonly",
|
||||
onDeactivated: "readonly",
|
||||
onRenderTracked: "readonly",
|
||||
onRenderTriggered: "readonly",
|
||||
onServerPrefetch: "readonly",
|
||||
},
|
||||
};
|
||||
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -20,7 +20,10 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "22.x"
|
||||
# 安装 pnpm
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
# 复制环境变量文件
|
||||
- name: Copy .env.example
|
||||
run: |
|
||||
@@ -31,16 +34,15 @@ jobs:
|
||||
}
|
||||
# 安装项目依赖
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
run: pnpm install
|
||||
# 构建 Electron App
|
||||
- name: Build Electron App
|
||||
run: npm run build:win
|
||||
run: pnpm run build:win
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
# 清理不必要的构建产物
|
||||
- name: Cleanup Artifacts
|
||||
run: |
|
||||
npx rimraf "dist/!(*.exe)"
|
||||
run: npx del-cli "dist/**/*" "!dist/*.exe"
|
||||
# 上传构建产物
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
49
.github/workflows/release.yml
vendored
49
.github/workflows/release.yml
vendored
@@ -6,13 +6,13 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Windows
|
||||
build-windows:
|
||||
name: Build for Windows
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# 检出 Git 仓库
|
||||
- name: Check out git repository
|
||||
@@ -21,7 +21,10 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "22.x"
|
||||
# 安装 pnpm
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
# 复制环境变量文件
|
||||
- name: Copy .env.example
|
||||
run: |
|
||||
@@ -32,11 +35,10 @@ jobs:
|
||||
}
|
||||
# 安装项目依赖
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
run: pnpm install
|
||||
# 构建 Electron App
|
||||
- name: Build Electron App for Windows
|
||||
run: npm run build:win || true
|
||||
shell: bash
|
||||
run: pnpm run build:win || true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
# 上传构建产物
|
||||
@@ -51,7 +53,6 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
with:
|
||||
draft: true
|
||||
files: dist/*.*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
@@ -59,7 +60,6 @@ jobs:
|
||||
build-macos:
|
||||
name: Build for macOS
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# 检出 Git 仓库
|
||||
- name: Check out git repository
|
||||
@@ -69,6 +69,9 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
# 安装 pnpm
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
# 复制环境变量文件
|
||||
- name: Copy .env.example
|
||||
run: |
|
||||
@@ -79,10 +82,10 @@ jobs:
|
||||
fi
|
||||
# 安装项目依赖
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
run: pnpm install
|
||||
# 构建 Electron App
|
||||
- name: Build Electron App for macOS
|
||||
run: npm run build:mac || true
|
||||
run: pnpm run build:mac || true
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
@@ -98,7 +101,6 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
with:
|
||||
draft: true
|
||||
files: dist/*.*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
@@ -106,7 +108,6 @@ jobs:
|
||||
build-linux:
|
||||
name: Build for Linux
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# 检出 Git 仓库
|
||||
- name: Check out git repository
|
||||
@@ -116,9 +117,23 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
# 安装 pnpm
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
# 更新 Ubuntu 软件源
|
||||
- name: Ubuntu Update with sudo
|
||||
run: sudo apt-get update
|
||||
# 安装依赖
|
||||
- name: Install RPM & Pacman
|
||||
run: |
|
||||
sudo apt-get install --no-install-recommends -y rpm &&
|
||||
sudo apt-get install --no-install-recommends -y libarchive-tools &&
|
||||
sudo apt-get install --no-install-recommends -y libopenjp2-tools
|
||||
# 安装 Snapcraft
|
||||
- name: Install Snapcraft
|
||||
uses: samuelmeuli/action-snapcraft@v2
|
||||
with:
|
||||
snapcraft_token: ${{ secrets.SNAPCRAFT_TOKEN }}
|
||||
# 复制环境变量文件
|
||||
- name: Copy .env.example
|
||||
run: |
|
||||
@@ -129,13 +144,20 @@ jobs:
|
||||
fi
|
||||
# 安装项目依赖
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
run: pnpm install
|
||||
# 构建 Electron App
|
||||
- name: Build Electron App for Linux
|
||||
run: npm run build:linux || true
|
||||
run: pnpm run build:linux || true
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }}
|
||||
# 上传 Snap 包到 Snapcraft 商店
|
||||
- name: Publish Snap to Snap Store
|
||||
run: snapcraft upload dist/*.snap --release stable
|
||||
continue-on-error: true
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }}
|
||||
# 上传构建产物
|
||||
- name: Upload Linux artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -148,7 +170,6 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
with:
|
||||
draft: true
|
||||
files: dist/*.*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -28,3 +28,5 @@ components.d.ts
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env.development
|
||||
.env.production
|
||||
14
Dockerfile
14
Dockerfile
@@ -23,8 +23,16 @@ COPY --from=builder /app/out/renderer /usr/share/nginx/html
|
||||
|
||||
COPY --from=builder /app/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
RUN apk add --no-cache npm
|
||||
COPY --from=builder /app/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
RUN npm install -g NeteaseCloudMusicApi
|
||||
RUN apk add --no-cache npm python3 youtube-dl \
|
||||
&& npm install -g @unblockneteasemusic/server NeteaseCloudMusicApi \
|
||||
&& wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -O /usr/local/bin/yt-dlp \
|
||||
&& chmod +x /usr/local/bin/yt-dlp \
|
||||
&& chmod +x /docker-entrypoint.sh
|
||||
|
||||
CMD nginx && npx NeteaseCloudMusicApi
|
||||
ENV NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
CMD ["npx", "NeteaseCloudMusicApi"]
|
||||
12
README.md
12
README.md
@@ -1,6 +1,12 @@
|
||||
# SPlayer
|
||||
|
||||
> 一个简约的音乐播放器
|
||||
> A simple music player
|
||||
|
||||

|
||||

|
||||
[](https://github.com/imsyy/SPlayer/actions/workflows/release.yml)
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
@@ -117,6 +123,10 @@
|
||||
|
||||
[Dev Workflow](https://github.com/imsyy/SPlayer/actions/workflows/build.yml)
|
||||
|
||||
## Snap Store
|
||||
|
||||
[](https://snapcraft.io/splayer)
|
||||
|
||||
## ⚙️ Docker 部署
|
||||
|
||||
> 安装及配置 `Docker` 将不在此处说明,请自行解决
|
||||
|
||||
309
auto-eslint.mjs
Normal file
309
auto-eslint.mjs
Normal file
@@ -0,0 +1,309 @@
|
||||
export default {
|
||||
"globals": {
|
||||
"Component": true,
|
||||
"ComponentPublicInstance": true,
|
||||
"ComputedRef": true,
|
||||
"DirectiveBinding": true,
|
||||
"EffectScope": true,
|
||||
"ExtractDefaultPropTypes": true,
|
||||
"ExtractPropTypes": true,
|
||||
"ExtractPublicPropTypes": true,
|
||||
"InjectionKey": true,
|
||||
"MaybeRef": true,
|
||||
"MaybeRefOrGetter": true,
|
||||
"PropType": true,
|
||||
"Ref": true,
|
||||
"VNode": true,
|
||||
"WritableComputedRef": true,
|
||||
"asyncComputed": true,
|
||||
"autoResetRef": true,
|
||||
"computed": true,
|
||||
"computedAsync": true,
|
||||
"computedEager": true,
|
||||
"computedInject": true,
|
||||
"computedWithControl": true,
|
||||
"controlledComputed": true,
|
||||
"controlledRef": true,
|
||||
"createApp": true,
|
||||
"createEventHook": true,
|
||||
"createGlobalState": true,
|
||||
"createInjectionState": true,
|
||||
"createReactiveFn": true,
|
||||
"createRef": true,
|
||||
"createReusableTemplate": true,
|
||||
"createSharedComposable": true,
|
||||
"createTemplatePromise": true,
|
||||
"createUnrefFn": true,
|
||||
"customRef": true,
|
||||
"debouncedRef": true,
|
||||
"debouncedWatch": true,
|
||||
"defineAsyncComponent": true,
|
||||
"defineComponent": true,
|
||||
"eagerComputed": true,
|
||||
"effectScope": true,
|
||||
"extendRef": true,
|
||||
"getCurrentInstance": true,
|
||||
"getCurrentScope": true,
|
||||
"h": true,
|
||||
"ignorableWatch": true,
|
||||
"inject": true,
|
||||
"injectLocal": true,
|
||||
"isDefined": true,
|
||||
"isProxy": true,
|
||||
"isReactive": true,
|
||||
"isReadonly": true,
|
||||
"isRef": true,
|
||||
"makeDestructurable": true,
|
||||
"markRaw": true,
|
||||
"nextTick": true,
|
||||
"onActivated": true,
|
||||
"onBeforeMount": true,
|
||||
"onBeforeRouteLeave": true,
|
||||
"onBeforeRouteUpdate": true,
|
||||
"onBeforeUnmount": true,
|
||||
"onBeforeUpdate": true,
|
||||
"onClickOutside": true,
|
||||
"onDeactivated": true,
|
||||
"onElementRemoval": true,
|
||||
"onErrorCaptured": true,
|
||||
"onKeyStroke": true,
|
||||
"onLongPress": true,
|
||||
"onMounted": true,
|
||||
"onRenderTracked": true,
|
||||
"onRenderTriggered": true,
|
||||
"onScopeDispose": true,
|
||||
"onServerPrefetch": true,
|
||||
"onStartTyping": true,
|
||||
"onUnmounted": true,
|
||||
"onUpdated": true,
|
||||
"onWatcherCleanup": true,
|
||||
"pausableWatch": true,
|
||||
"provide": true,
|
||||
"provideLocal": true,
|
||||
"reactify": true,
|
||||
"reactifyObject": true,
|
||||
"reactive": true,
|
||||
"reactiveComputed": true,
|
||||
"reactiveOmit": true,
|
||||
"reactivePick": true,
|
||||
"readonly": true,
|
||||
"ref": true,
|
||||
"refAutoReset": true,
|
||||
"refDebounced": true,
|
||||
"refDefault": true,
|
||||
"refThrottled": true,
|
||||
"refWithControl": true,
|
||||
"resolveComponent": true,
|
||||
"resolveRef": true,
|
||||
"resolveUnref": true,
|
||||
"shallowReactive": true,
|
||||
"shallowReadonly": true,
|
||||
"shallowRef": true,
|
||||
"syncRef": true,
|
||||
"syncRefs": true,
|
||||
"templateRef": true,
|
||||
"throttledRef": true,
|
||||
"throttledWatch": true,
|
||||
"toRaw": true,
|
||||
"toReactive": true,
|
||||
"toRef": true,
|
||||
"toRefs": true,
|
||||
"toValue": true,
|
||||
"triggerRef": true,
|
||||
"tryOnBeforeMount": true,
|
||||
"tryOnBeforeUnmount": true,
|
||||
"tryOnMounted": true,
|
||||
"tryOnScopeDispose": true,
|
||||
"tryOnUnmounted": true,
|
||||
"unref": true,
|
||||
"unrefElement": true,
|
||||
"until": true,
|
||||
"useActiveElement": true,
|
||||
"useAnimate": true,
|
||||
"useArrayDifference": true,
|
||||
"useArrayEvery": true,
|
||||
"useArrayFilter": true,
|
||||
"useArrayFind": true,
|
||||
"useArrayFindIndex": true,
|
||||
"useArrayFindLast": true,
|
||||
"useArrayIncludes": true,
|
||||
"useArrayJoin": true,
|
||||
"useArrayMap": true,
|
||||
"useArrayReduce": true,
|
||||
"useArraySome": true,
|
||||
"useArrayUnique": true,
|
||||
"useAsyncQueue": true,
|
||||
"useAsyncState": true,
|
||||
"useAttrs": true,
|
||||
"useBase64": true,
|
||||
"useBattery": true,
|
||||
"useBluetooth": true,
|
||||
"useBreakpoints": true,
|
||||
"useBroadcastChannel": true,
|
||||
"useBrowserLocation": true,
|
||||
"useCached": true,
|
||||
"useClipboard": true,
|
||||
"useClipboardItems": true,
|
||||
"useCloned": true,
|
||||
"useColorMode": true,
|
||||
"useConfirmDialog": true,
|
||||
"useCountdown": true,
|
||||
"useCounter": true,
|
||||
"useCssModule": true,
|
||||
"useCssVar": true,
|
||||
"useCssVars": true,
|
||||
"useCurrentElement": true,
|
||||
"useCycleList": true,
|
||||
"useDark": true,
|
||||
"useDateFormat": true,
|
||||
"useDebounce": true,
|
||||
"useDebounceFn": true,
|
||||
"useDebouncedRefHistory": true,
|
||||
"useDeviceMotion": true,
|
||||
"useDeviceOrientation": true,
|
||||
"useDevicePixelRatio": true,
|
||||
"useDevicesList": true,
|
||||
"useDialog": true,
|
||||
"useDisplayMedia": true,
|
||||
"useDocumentVisibility": true,
|
||||
"useDraggable": true,
|
||||
"useDropZone": true,
|
||||
"useElementBounding": true,
|
||||
"useElementByPoint": true,
|
||||
"useElementHover": true,
|
||||
"useElementSize": true,
|
||||
"useElementVisibility": true,
|
||||
"useEventBus": true,
|
||||
"useEventListener": true,
|
||||
"useEventSource": true,
|
||||
"useEyeDropper": true,
|
||||
"useFavicon": true,
|
||||
"useFetch": true,
|
||||
"useFileDialog": true,
|
||||
"useFileSystemAccess": true,
|
||||
"useFocus": true,
|
||||
"useFocusWithin": true,
|
||||
"useFps": true,
|
||||
"useFullscreen": true,
|
||||
"useGamepad": true,
|
||||
"useGeolocation": true,
|
||||
"useId": true,
|
||||
"useIdle": true,
|
||||
"useImage": true,
|
||||
"useInfiniteScroll": true,
|
||||
"useIntersectionObserver": true,
|
||||
"useInterval": true,
|
||||
"useIntervalFn": true,
|
||||
"useKeyModifier": true,
|
||||
"useLastChanged": true,
|
||||
"useLink": true,
|
||||
"useLoadingBar": true,
|
||||
"useLocalStorage": true,
|
||||
"useMagicKeys": true,
|
||||
"useManualRefHistory": true,
|
||||
"useMediaControls": true,
|
||||
"useMediaQuery": true,
|
||||
"useMemoize": true,
|
||||
"useMemory": true,
|
||||
"useMessage": true,
|
||||
"useModel": true,
|
||||
"useMounted": true,
|
||||
"useMouse": true,
|
||||
"useMouseInElement": true,
|
||||
"useMousePressed": true,
|
||||
"useMutationObserver": true,
|
||||
"useNavigatorLanguage": true,
|
||||
"useNetwork": true,
|
||||
"useNotification": true,
|
||||
"useNow": true,
|
||||
"useObjectUrl": true,
|
||||
"useOffsetPagination": true,
|
||||
"useOnline": true,
|
||||
"usePageLeave": true,
|
||||
"useParallax": true,
|
||||
"useParentElement": true,
|
||||
"usePerformanceObserver": true,
|
||||
"usePermission": true,
|
||||
"usePointer": true,
|
||||
"usePointerLock": true,
|
||||
"usePointerSwipe": true,
|
||||
"usePreferredColorScheme": true,
|
||||
"usePreferredContrast": true,
|
||||
"usePreferredDark": true,
|
||||
"usePreferredLanguages": true,
|
||||
"usePreferredReducedMotion": true,
|
||||
"usePreferredReducedTransparency": true,
|
||||
"usePrevious": true,
|
||||
"useRafFn": true,
|
||||
"useRefHistory": true,
|
||||
"useResizeObserver": true,
|
||||
"useRoute": true,
|
||||
"useRouter": true,
|
||||
"useSSRWidth": true,
|
||||
"useScreenOrientation": true,
|
||||
"useScreenSafeArea": true,
|
||||
"useScriptTag": true,
|
||||
"useScroll": true,
|
||||
"useScrollLock": true,
|
||||
"useSessionStorage": true,
|
||||
"useShare": true,
|
||||
"useSlots": true,
|
||||
"useSorted": true,
|
||||
"useSpeechRecognition": true,
|
||||
"useSpeechSynthesis": true,
|
||||
"useStepper": true,
|
||||
"useStorage": true,
|
||||
"useStorageAsync": true,
|
||||
"useStyleTag": true,
|
||||
"useSupported": true,
|
||||
"useSwipe": true,
|
||||
"useTemplateRef": true,
|
||||
"useTemplateRefsList": true,
|
||||
"useTextDirection": true,
|
||||
"useTextSelection": true,
|
||||
"useTextareaAutosize": true,
|
||||
"useThrottle": true,
|
||||
"useThrottleFn": true,
|
||||
"useThrottledRefHistory": true,
|
||||
"useTimeAgo": true,
|
||||
"useTimeout": true,
|
||||
"useTimeoutFn": true,
|
||||
"useTimeoutPoll": true,
|
||||
"useTimestamp": true,
|
||||
"useTitle": true,
|
||||
"useToNumber": true,
|
||||
"useToString": true,
|
||||
"useToggle": true,
|
||||
"useTransition": true,
|
||||
"useUrlSearchParams": true,
|
||||
"useUserMedia": true,
|
||||
"useVModel": true,
|
||||
"useVModels": true,
|
||||
"useVibrate": true,
|
||||
"useVirtualList": true,
|
||||
"useWakeLock": true,
|
||||
"useWebNotification": true,
|
||||
"useWebSocket": true,
|
||||
"useWebWorker": true,
|
||||
"useWebWorkerFn": true,
|
||||
"useWindowFocus": true,
|
||||
"useWindowScroll": true,
|
||||
"useWindowSize": true,
|
||||
"watch": true,
|
||||
"watchArray": true,
|
||||
"watchAtMost": true,
|
||||
"watchDebounced": true,
|
||||
"watchDeep": true,
|
||||
"watchEffect": true,
|
||||
"watchIgnorable": true,
|
||||
"watchImmediate": true,
|
||||
"watchOnce": true,
|
||||
"watchPausable": true,
|
||||
"watchPostEffect": true,
|
||||
"watchSyncEffect": true,
|
||||
"watchThrottled": true,
|
||||
"watchTriggerable": true,
|
||||
"watchWithFilter": true,
|
||||
"whenever": true
|
||||
}
|
||||
}
|
||||
12
auto-imports.d.ts
vendored
12
auto-imports.d.ts
vendored
@@ -3,6 +3,7 @@
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
@@ -20,6 +21,7 @@ declare global {
|
||||
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']
|
||||
@@ -54,6 +56,7 @@ declare global {
|
||||
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']
|
||||
@@ -65,6 +68,7 @@ declare global {
|
||||
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']
|
||||
@@ -135,6 +139,7 @@ declare global {
|
||||
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']
|
||||
@@ -174,6 +179,7 @@ declare global {
|
||||
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']
|
||||
@@ -192,6 +198,7 @@ declare global {
|
||||
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']
|
||||
@@ -217,12 +224,14 @@ declare global {
|
||||
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']
|
||||
@@ -240,6 +249,7 @@ declare global {
|
||||
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']
|
||||
@@ -291,6 +301,6 @@ declare global {
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
|
||||
15
components.d.ts
vendored
15
components.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
@@ -17,14 +18,17 @@ declare module 'vue' {
|
||||
CoverMenu: typeof import('./src/components/Menu/CoverMenu.vue')['default']
|
||||
CreatePlaylist: typeof import('./src/components/Modal/CreatePlaylist.vue')['default']
|
||||
DownloadSong: typeof import('./src/components/Modal/DownloadSong.vue')['default']
|
||||
ExcludeKeywords: typeof import('./src/components/Modal/ExcludeKeywords.vue')['default']
|
||||
FullPlayer: typeof import('./src/components/Player/FullPlayer.vue')['default']
|
||||
GeneralSetting: typeof import('./src/components/Setting/GeneralSetting.vue')['default']
|
||||
JumpArtist: typeof import('./src/components/Modal/JumpArtist.vue')['default']
|
||||
KeyboardSetting: typeof import('./src/components/Setting/KeyboardSetting.vue')['default']
|
||||
LocalSetting: typeof import('./src/components/Setting/LocalSetting.vue')['default']
|
||||
Login: typeof import('./src/components/Modal/Login.vue')['default']
|
||||
LoginPhone: typeof import('./src/components/Modal/loginPhone.vue')['default']
|
||||
LoginQRCode: typeof import('./src/components/Modal/loginQRCode.vue')['default']
|
||||
Login: typeof import('./src/components/Modal/Login/Login.vue')['default']
|
||||
LoginCookie: typeof import('./src/components/Modal/Login/LoginCookie.vue')['default']
|
||||
LoginPhone: typeof import('./src/components/Modal/Login/LoginPhone.vue')['default']
|
||||
LoginQRCode: typeof import('./src/components/Modal/Login/LoginQRCode.vue')['default']
|
||||
LoginUID: typeof import('./src/components/Modal/Login/LoginUID.vue')['default']
|
||||
LyricsSetting: typeof import('./src/components/Setting/LyricsSetting.vue')['default']
|
||||
MainAMLyric: typeof import('./src/components/Player/MainAMLyric.vue')['default']
|
||||
MainLyric: typeof import('./src/components/Player/MainLyric.vue')['default']
|
||||
@@ -52,6 +56,7 @@ declare module 'vue' {
|
||||
NDrawer: typeof import('naive-ui')['NDrawer']
|
||||
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
|
||||
NDropdown: typeof import('naive-ui')['NDropdown']
|
||||
NDynamicTags: typeof import('naive-ui')['NDynamicTags']
|
||||
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NFlex: typeof import('naive-ui')['NFlex']
|
||||
@@ -87,11 +92,9 @@ declare module 'vue' {
|
||||
NOl: typeof import('naive-ui')['NOl']
|
||||
NP: typeof import('naive-ui')['NP']
|
||||
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']
|
||||
@@ -124,6 +127,8 @@ declare module 'vue' {
|
||||
SearchInpMenu: typeof import('./src/components/Menu/SearchInpMenu.vue')['default']
|
||||
SearchSuggest: typeof import('./src/components/Search/SearchSuggest.vue')['default']
|
||||
Sider: typeof import('./src/components/Layout/Sider.vue')['default']
|
||||
SImage: typeof import('./src/components/UI/s-image.vue')['default']
|
||||
SongCard: typeof import('./src/components/Card/SongCard.vue')['default']
|
||||
SongDataCard: typeof import('./src/components/Card/SongDataCard.vue')['default']
|
||||
SongInfoEditor: typeof import('./src/components/Modal/SongInfoEditor.vue')['default']
|
||||
SongList: typeof import('./src/components/List/SongList.vue')['default']
|
||||
|
||||
@@ -10,3 +10,21 @@ services:
|
||||
ports:
|
||||
- 25884:25884
|
||||
restart: always
|
||||
environment:
|
||||
# 所有变量都不是必填项
|
||||
# 网易云服务端 IP, 可在宿主机通过 ping music.163.com 获得
|
||||
- NETEASE_SERVER_IP=220.197.30.65
|
||||
# UnblockNeteaseMusic 使用的音源, 支持列表见 https://github.com/UnblockNeteaseMusic/server?tab=readme-ov-file#%E9%9F%B3%E6%BA%90%E6%B8%85%E5%8D%95
|
||||
- UNBLOCK_SOURCES=kugou kuwo bilibili
|
||||
# 可添加 UnblockNeteaseMusic 支持的任何环境变量, 支持列表见 https://github.com/UnblockNeteaseMusic/server?tab=readme-ov-file#%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F
|
||||
- ENABLE_FLAC=false
|
||||
- ENABLE_HTTPDNS=false
|
||||
- BLOCK_ADS=true
|
||||
- DISABLE_UPGRADE_CHECK=false
|
||||
- DEVELOPMENT=false
|
||||
- FOLLOW_SOURCE_ORDER=true
|
||||
- JSON_LOG=false
|
||||
- NO_CACHE=false
|
||||
- SELECT_MAX_BR=true
|
||||
- LOG_LEVEL=info
|
||||
- SEARCH_ALBUM=true
|
||||
|
||||
29
docker-entrypoint.sh
Normal file
29
docker-entrypoint.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
# start unblock service in the background
|
||||
npx unblockneteasemusic -p 80:443 -s -f ${NETEASE_SERVER_IP:-220.197.30.65} -o ${UNBLOCK_SOURCES:-kugou kuwo bilibili} 2>&1 &
|
||||
|
||||
# point the neteasemusic address to the unblock service
|
||||
if ! grep -q "music.163.com" /etc/hosts; then
|
||||
echo "127.0.0.1 music.163.com" >> /etc/hosts
|
||||
fi
|
||||
if ! grep -q "interface.music.163.com" /etc/hosts; then
|
||||
echo "127.0.0.1 interface.music.163.com" >> /etc/hosts
|
||||
fi
|
||||
if ! grep -q "interface3.music.163.com" /etc/hosts; then
|
||||
echo "127.0.0.1 interface3.music.163.com" >> /etc/hosts
|
||||
fi
|
||||
if ! grep -q "interface.music.163.com.163jiasu.com" /etc/hosts; then
|
||||
echo "127.0.0.1 interface.music.163.com.163jiasu.com" >> /etc/hosts
|
||||
fi
|
||||
if ! grep -q "interface3.music.163.com.163jiasu.com" /etc/hosts; then
|
||||
echo "127.0.0.1 interface3.music.163.com.163jiasu.com" >> /etc/hosts
|
||||
fi
|
||||
|
||||
# start the nginx daemon
|
||||
nginx
|
||||
|
||||
# start the main process
|
||||
exec "$@"
|
||||
@@ -84,7 +84,7 @@ dmg:
|
||||
# Linux 平台配置
|
||||
linux:
|
||||
# 可执行文件名
|
||||
executableName: SPlayer
|
||||
executableName: splayer
|
||||
# 应用程序的图标文件路径
|
||||
icon: public/icons/favicon-512x512.png
|
||||
# 构建类型
|
||||
@@ -93,6 +93,7 @@ linux:
|
||||
- AppImage
|
||||
- deb
|
||||
- rpm
|
||||
- snap
|
||||
- tar.gz
|
||||
# 维护者信息
|
||||
maintainer: imsyy.top
|
||||
|
||||
@@ -60,7 +60,7 @@ export default defineConfig(({ command, mode }) => {
|
||||
],
|
||||
eslintrc: {
|
||||
enabled: true,
|
||||
filepath: "./.eslintrc-auto-import.json",
|
||||
filepath: "./auto-eslint.mjs",
|
||||
},
|
||||
}),
|
||||
Components({
|
||||
@@ -74,6 +74,13 @@ export default defineConfig(({ command, mode }) => {
|
||||
"@": resolve(__dirname, "src/"),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
silenceDeprecations: ["legacy-js-api"],
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: webPort,
|
||||
// 代理
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { app, shell, BrowserWindow, BrowserWindowConstructorOptions } from "electron";
|
||||
import { electronApp, optimizer } from "@electron-toolkit/utils";
|
||||
import { electronApp } from "@electron-toolkit/utils";
|
||||
import { join } from "path";
|
||||
import { release } from "os";
|
||||
import { release, type } from "os";
|
||||
import { isDev, isMac, appName } from "./utils";
|
||||
import { registerAllShortcuts, unregisterShortcuts } from "./shortcut";
|
||||
import { unregisterShortcuts } from "./shortcut";
|
||||
import { initTray, MainTray } from "./tray";
|
||||
import { initThumbar, Thumbar } from "./thumbar";
|
||||
import { type StoreType, initStore } from "./store";
|
||||
import Store from "electron-store";
|
||||
import initAppServer from "../server";
|
||||
import initIpcMain from "./ipcMain";
|
||||
import log from "./logger";
|
||||
import store from "./store";
|
||||
// icon
|
||||
import icon from "../../public/icons/favicon.png?asset";
|
||||
|
||||
@@ -29,6 +30,8 @@ class MainProcess {
|
||||
mainWindow: BrowserWindow | null = null;
|
||||
lyricWindow: BrowserWindow | null = null;
|
||||
loadingWindow: BrowserWindow | null = null;
|
||||
// store
|
||||
store: Store<StoreType> | null = null;
|
||||
// 托盘
|
||||
mainTray: MainTray | null = null;
|
||||
// 工具栏
|
||||
@@ -38,7 +41,7 @@ class MainProcess {
|
||||
constructor() {
|
||||
log.info("🚀 Main process startup");
|
||||
// 禁用 Windows 7 的 GPU 加速功能
|
||||
if (release().startsWith("6.1")) app.disableHardwareAcceleration();
|
||||
if (release().startsWith("6.1") && type() == "Windows_NT") app.disableHardwareAcceleration();
|
||||
// 单例锁
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
log.error("❌ There is already a program running and this process is terminated");
|
||||
@@ -46,10 +49,12 @@ class MainProcess {
|
||||
process.exit(0);
|
||||
} else this.showWindow();
|
||||
// 准备就绪
|
||||
app.whenReady().then(async () => {
|
||||
app.on("ready", async () => {
|
||||
log.info("🚀 Application Process Startup");
|
||||
// 设置应用程序名称
|
||||
electronApp.setAppUserModelId(app.getName());
|
||||
electronApp.setAppUserModelId("com.imsyy.splayer");
|
||||
// 初始化 store
|
||||
this.store = initStore();
|
||||
// 启动主服务进程
|
||||
await initAppServer();
|
||||
// 启动进程
|
||||
@@ -68,10 +73,8 @@ class MainProcess {
|
||||
this.loadingWindow,
|
||||
this.mainTray,
|
||||
this.thumbar,
|
||||
store,
|
||||
this.store,
|
||||
);
|
||||
// 注册快捷键
|
||||
registerAllShortcuts(this.mainWindow!);
|
||||
});
|
||||
}
|
||||
// 创建窗口
|
||||
@@ -111,8 +114,8 @@ class MainProcess {
|
||||
createMainWindow() {
|
||||
// 窗口配置项
|
||||
const options: BrowserWindowConstructorOptions = {
|
||||
width: store.get("window").width,
|
||||
height: store.get("window").height,
|
||||
width: this.store?.get("window").width,
|
||||
height: this.store?.get("window").height,
|
||||
minHeight: 800,
|
||||
minWidth: 1280,
|
||||
// 菜单栏
|
||||
@@ -132,8 +135,8 @@ class MainProcess {
|
||||
}
|
||||
|
||||
// 配置网络代理
|
||||
if (store.get("proxy")) {
|
||||
this.mainWindow.webContents.session.setProxy({ proxyRules: store.get("proxy") });
|
||||
if (this.store?.get("proxy")) {
|
||||
this.mainWindow.webContents.session.setProxy({ proxyRules: this.store?.get("proxy") });
|
||||
}
|
||||
|
||||
// 窗口打开处理程序
|
||||
@@ -162,15 +165,15 @@ class MainProcess {
|
||||
createLyricsWindow() {
|
||||
// 初始化窗口
|
||||
this.lyricWindow = this.createWindow({
|
||||
width: store.get("lyric").width || 800,
|
||||
height: store.get("lyric").height || 180,
|
||||
width: this.store?.get("lyric").width || 800,
|
||||
height: this.store?.get("lyric").height || 180,
|
||||
minWidth: 440,
|
||||
minHeight: 120,
|
||||
maxWidth: 1600,
|
||||
maxHeight: 300,
|
||||
// 窗口位置
|
||||
x: store.get("lyric").x,
|
||||
y: store.get("lyric").y,
|
||||
x: this.store?.get("lyric").x,
|
||||
y: this.store?.get("lyric").y,
|
||||
transparent: true,
|
||||
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||
alwaysOnTop: true,
|
||||
@@ -213,11 +216,6 @@ class MainProcess {
|
||||
this.showWindow();
|
||||
});
|
||||
|
||||
// 开发环境控制台
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
// 自定义协议
|
||||
app.on("open-url", (_, url) => {
|
||||
console.log("Received custom protocol URL:", url);
|
||||
@@ -236,6 +234,10 @@ class MainProcess {
|
||||
}
|
||||
// 窗口事件
|
||||
handleWindowEvents() {
|
||||
this.mainWindow?.on("ready-to-show", () => {
|
||||
if (!this.mainWindow) return;
|
||||
this.thumbar = initThumbar(this.mainWindow);
|
||||
});
|
||||
this.mainWindow?.on("show", () => {
|
||||
// this.mainWindow?.webContents.send("lyricsScroll");
|
||||
});
|
||||
@@ -257,7 +259,7 @@ class MainProcess {
|
||||
const bounds = this.lyricWindow?.getBounds();
|
||||
if (bounds) {
|
||||
const { width, height } = bounds;
|
||||
store.set("lyric", { ...store.get("lyric"), width, height });
|
||||
this.store?.set("lyric", { ...this.store?.get("lyric"), width, height });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -275,7 +277,7 @@ class MainProcess {
|
||||
saveBounds() {
|
||||
if (this.mainWindow?.isFullScreen()) return;
|
||||
const bounds = this.mainWindow?.getBounds();
|
||||
if (bounds) store.set("window", bounds);
|
||||
if (bounds) this.store?.set("window", bounds);
|
||||
}
|
||||
// 显示窗口
|
||||
showWindow() {
|
||||
|
||||
@@ -17,13 +17,14 @@ import { Thumbar } from "./thumbar";
|
||||
import { StoreType } from "./store";
|
||||
import { isDev, getFileID, getFileMD5 } from "./utils";
|
||||
import { isShortcutRegistered, registerShortcut, unregisterShortcuts } from "./shortcut";
|
||||
import { join, basename, resolve } from "path";
|
||||
import { join, basename, resolve, relative, isAbsolute } from "path";
|
||||
import { download } from "electron-dl";
|
||||
import { checkUpdate, startDownloadUpdate } from "./update";
|
||||
import fs from "fs/promises";
|
||||
import log from "../main/logger";
|
||||
import Store from "electron-store";
|
||||
import fg from "fast-glob";
|
||||
import openLoginWin from "./loginWin";
|
||||
|
||||
// 注册 ipcMain
|
||||
const initIpcMain = (
|
||||
@@ -142,8 +143,10 @@ const initWinIpcMain = (
|
||||
|
||||
// 切换桌面歌词
|
||||
ipcMain.on("change-desktop-lyric", (_, val: boolean) => {
|
||||
val ? lyricWin?.show() : lyricWin?.hide();
|
||||
if (val) lyricWin?.setAlwaysOnTop(true, "screen-saver");
|
||||
if (val) {
|
||||
lyricWin?.show();
|
||||
lyricWin?.setAlwaysOnTop(true, "screen-saver");
|
||||
} else lyricWin?.hide();
|
||||
});
|
||||
|
||||
// 是否阻止系统息屏
|
||||
@@ -170,8 +173,11 @@ const initWinIpcMain = (
|
||||
// 遍历音乐文件
|
||||
ipcMain.handle("get-music-files", async (_, dirPath: string) => {
|
||||
try {
|
||||
// 规范化路径
|
||||
const filePath = resolve(dirPath).replace(/\\/g, "/");
|
||||
console.info(`📂 Fetching music files from: ${filePath}`);
|
||||
// 查找指定目录下的所有音乐文件
|
||||
const musicFiles = await fg("**/*.{mp3,wav,flac}", { cwd: dirPath });
|
||||
const musicFiles = await fg("**/*.{mp3,wav,flac}", { cwd: filePath });
|
||||
// 解析元信息
|
||||
const metadataPromises = musicFiles.map(async (file) => {
|
||||
const filePath = join(dirPath, file);
|
||||
@@ -211,18 +217,19 @@ const initWinIpcMain = (
|
||||
// 获取音乐元信息
|
||||
ipcMain.handle("get-music-metadata", async (_, path: string) => {
|
||||
try {
|
||||
const { common, format } = await parseFile(path);
|
||||
const filePath = resolve(path).replace(/\\/g, "/");
|
||||
const { common, format } = await parseFile(filePath);
|
||||
return {
|
||||
// 文件名称
|
||||
fileName: basename(path),
|
||||
fileName: basename(filePath),
|
||||
// 文件大小
|
||||
fileSize: (await fs.stat(path)).size / (1024 * 1024),
|
||||
fileSize: (await fs.stat(filePath)).size / (1024 * 1024),
|
||||
// 元信息
|
||||
common,
|
||||
// 音质信息
|
||||
format,
|
||||
// md5
|
||||
md5: await getFileMD5(path),
|
||||
md5: await getFileMD5(filePath),
|
||||
};
|
||||
} catch (error) {
|
||||
log.error("❌ Error fetching music metadata:", error);
|
||||
@@ -233,24 +240,19 @@ const initWinIpcMain = (
|
||||
// 获取音乐歌词
|
||||
ipcMain.handle("get-music-lyric", async (_, path: string): Promise<string> => {
|
||||
try {
|
||||
const { common, native } = await parseFile(path);
|
||||
const filePath = resolve(path).replace(/\\/g, "/");
|
||||
const { common } = await parseFile(filePath);
|
||||
const lyric = common?.lyrics;
|
||||
if (lyric && lyric.length > 0) return String(lyric[0]);
|
||||
// 如果歌词数据不存在,尝试读取同名的 lrc 文件
|
||||
else {
|
||||
// 尝试读取 UNSYNCEDLYRICS
|
||||
const nativeTags = native["ID3v2.3"] || native["ID3v2.4"];
|
||||
const usltTag = nativeTags?.find((tag) => tag.id === "USLT");
|
||||
if (usltTag) return String(usltTag.value.text);
|
||||
// 如果歌词数据不存在,尝试读取同名的 lrc 文件
|
||||
else {
|
||||
const lrcFilePath = path.replace(/\.[^.]+$/, ".lrc");
|
||||
try {
|
||||
await fs.access(lrcFilePath);
|
||||
const lrcData = await fs.readFile(lrcFilePath, "utf-8");
|
||||
return lrcData || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
const lrcFilePath = filePath.replace(/\.[^.]+$/, ".lrc");
|
||||
try {
|
||||
await fs.access(lrcFilePath);
|
||||
const lrcData = await fs.readFile(lrcFilePath, "utf-8");
|
||||
return lrcData || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -533,6 +535,9 @@ const initWinIpcMain = (
|
||||
|
||||
// 开始下载更新
|
||||
ipcMain.on("start-download-update", () => startDownloadUpdate());
|
||||
|
||||
// 新建窗口
|
||||
ipcMain.on("open-login-web", () => openLoginWin(win!));
|
||||
};
|
||||
|
||||
// lyric
|
||||
@@ -617,6 +622,16 @@ const initLyricIpcMain = (
|
||||
lyricWin.setIgnoreMouseEvents(false);
|
||||
}
|
||||
});
|
||||
|
||||
// 检查是否是子文件夹
|
||||
ipcMain.handle("check-if-subfolder", (_, localFilesPath: string[], selectedDir: string) => {
|
||||
const resolvedSelectedDir = resolve(selectedDir);
|
||||
const allPaths = localFilesPath.map((p) => resolve(p));
|
||||
return allPaths.some((existingPath) => {
|
||||
const relativePath = relative(existingPath, resolvedSelectedDir);
|
||||
return relativePath && !relativePath.startsWith("..") && !isAbsolute(relativePath);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// tray
|
||||
@@ -664,6 +679,10 @@ const initTrayIpcMain = (
|
||||
// thumbar
|
||||
const initThumbarIpcMain = (thumbar: Thumbar | null): void => {
|
||||
if (!thumbar) return;
|
||||
// 更新工具栏
|
||||
ipcMain.on("play-status-change", (_, playStatus: boolean) => {
|
||||
thumbar?.updateThumbar(playStatus);
|
||||
});
|
||||
};
|
||||
|
||||
// store
|
||||
|
||||
@@ -9,14 +9,14 @@ Object.assign(console, log.functions);
|
||||
|
||||
// 日志配置
|
||||
log.transports.file.level = "info";
|
||||
log.transports.file.maxSize = 2 * 1024 * 1024;
|
||||
log.transports.file.maxSize = 2 * 1024 * 1024; // 2M
|
||||
if (log.transports.ipc) log.transports.ipc.level = false;
|
||||
|
||||
// 控制台输出
|
||||
log.transports.console.useStyles = true;
|
||||
|
||||
// 文件输出
|
||||
log.transports.file.format = "{h}:{i}:{s}:{ms} {text}";
|
||||
log.transports.file.format = "{y}-{m}-{d} {h}:{i}:{s}:{ms} {text}";
|
||||
|
||||
// 本地输出
|
||||
if (!isDev) {
|
||||
|
||||
72
electron/main/loginWin.ts
Normal file
72
electron/main/loginWin.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { BrowserWindow, session } from "electron";
|
||||
import icon from "../../public/icons/favicon.png?asset";
|
||||
import { join } from "path";
|
||||
|
||||
const openLoginWin = async (mainWin: BrowserWindow) => {
|
||||
let loginTimer: NodeJS.Timeout;
|
||||
const loginSession = session.fromPartition("persist:login");
|
||||
// 清除 Cookie
|
||||
await loginSession.clearStorageData({
|
||||
storages: ["cookies", "localstorage"],
|
||||
});
|
||||
const loginWin = new BrowserWindow({
|
||||
parent: mainWin,
|
||||
title: "登录网易云音乐( 若遇到无响应请关闭后重试 )",
|
||||
width: 1280,
|
||||
height: 800,
|
||||
center: true,
|
||||
autoHideMenuBar: true,
|
||||
icon,
|
||||
// resizable: false,
|
||||
// movable: false,
|
||||
// minimizable: false,
|
||||
// maximizable: false,
|
||||
webPreferences: {
|
||||
session: loginSession,
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
preload: join(__dirname, "../preload/index.mjs"),
|
||||
},
|
||||
});
|
||||
|
||||
// 打开网易云
|
||||
loginWin.loadURL("https://music.163.com/#/login/");
|
||||
|
||||
// 阻止新窗口创建
|
||||
loginWin.webContents.setWindowOpenHandler(() => {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// 检查是否登录
|
||||
const checkLogin = async () => {
|
||||
try {
|
||||
loginWin.webContents.executeJavaScript(
|
||||
"document.title = '登录网易云音乐( 若遇到无响应请关闭后重试 )'",
|
||||
);
|
||||
// 是否登录?判断 MUSIC_U
|
||||
const MUSIC_U = await loginSession.cookies.get({
|
||||
name: "MUSIC_U",
|
||||
});
|
||||
if (MUSIC_U && MUSIC_U?.length > 0) {
|
||||
if (loginTimer) clearInterval(loginTimer);
|
||||
const value = `MUSIC_U=${MUSIC_U[0].value};`;
|
||||
// 发送回主进程
|
||||
mainWin?.webContents.send("send-cookies", value);
|
||||
loginWin.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 循环检查
|
||||
loginWin.webContents.once("did-finish-load", () => {
|
||||
loginWin.show();
|
||||
loginTimer = setInterval(checkLogin, 1000);
|
||||
loginWin.on("closed", () => {
|
||||
clearInterval(loginTimer);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export default openLoginWin;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BrowserWindow, globalShortcut } from "electron";
|
||||
import { isDev } from "./utils";
|
||||
import { globalShortcut } from "electron";
|
||||
import log from "../main/logger";
|
||||
|
||||
// 注册快捷键并检查
|
||||
@@ -29,15 +28,3 @@ export const unregisterShortcuts = () => {
|
||||
globalShortcut.unregisterAll();
|
||||
log.info("🚫 All shortcuts unregistered.");
|
||||
};
|
||||
|
||||
// 注册所有快捷键
|
||||
export const registerAllShortcuts = (win: BrowserWindow) => {
|
||||
// 开启控制台
|
||||
registerShortcut("CmdOrCtrl+Shift+I", () => {
|
||||
win.webContents.openDevTools({
|
||||
title: "SPlayer DevTools",
|
||||
// 客户端分离
|
||||
mode: isDev ? "right" : "detach",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Store from "electron-store";
|
||||
import { screen } from "electron";
|
||||
import log from "./logger";
|
||||
|
||||
log.info("🌱 Store init");
|
||||
@@ -24,23 +25,23 @@ export interface StoreType {
|
||||
}
|
||||
|
||||
// 初始化仓库
|
||||
const store = new Store<StoreType>({
|
||||
defaults: {
|
||||
window: {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
export const initStore = () => {
|
||||
return new Store<StoreType>({
|
||||
defaults: {
|
||||
window: {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
},
|
||||
lyric: {
|
||||
fontSize: 30,
|
||||
mainColor: "#fff",
|
||||
shadowColor: "rgba(0, 0, 0, 0.5)",
|
||||
x: screen.getPrimaryDisplay().workAreaSize.width / 2 - 400,
|
||||
y: screen.getPrimaryDisplay().workAreaSize.height - 90,
|
||||
width: 800,
|
||||
height: 180,
|
||||
},
|
||||
proxy: "",
|
||||
},
|
||||
lyric: {
|
||||
fontSize: 30,
|
||||
mainColor: "#fff",
|
||||
shadowColor: "rgba(0, 0, 0, 0.5)",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 800,
|
||||
height: 180,
|
||||
},
|
||||
proxy: "",
|
||||
},
|
||||
});
|
||||
|
||||
export default store;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ type ThumbarMap = Map<ThumbarKeys, ThumbarButton>;
|
||||
|
||||
export interface Thumbar {
|
||||
clearThumbar(): void;
|
||||
updateThumbar(playing: boolean, clean?: boolean): void;
|
||||
}
|
||||
|
||||
// 工具栏图标
|
||||
@@ -32,12 +33,12 @@ const createThumbarButtons = (win: BrowserWindow): ThumbarMap => {
|
||||
.set(ThumbarKeys.Prev, {
|
||||
tooltip: "上一曲",
|
||||
icon: thumbarIcon("prev"),
|
||||
click: () => win.webContents.send("play-prev"),
|
||||
click: () => win.webContents.send("playPrev"),
|
||||
})
|
||||
.set(ThumbarKeys.Next, {
|
||||
tooltip: "下一曲",
|
||||
icon: thumbarIcon("next"),
|
||||
click: () => win.webContents.send("play-next"),
|
||||
click: () => win.webContents.send("playNext"),
|
||||
})
|
||||
.set(ThumbarKeys.Play, {
|
||||
tooltip: "播放",
|
||||
@@ -47,7 +48,7 @@ const createThumbarButtons = (win: BrowserWindow): ThumbarMap => {
|
||||
.set(ThumbarKeys.Pause, {
|
||||
tooltip: "暂停",
|
||||
icon: thumbarIcon("pause"),
|
||||
click: () => win.webContents.send("play-pause"),
|
||||
click: () => win.webContents.send("pause"),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -75,7 +76,7 @@ class createThumbar implements Thumbar {
|
||||
this.updateThumbar();
|
||||
}
|
||||
// 更新工具栏
|
||||
private updateThumbar(playing: boolean = false, clean: boolean = false) {
|
||||
updateThumbar(playing: boolean = false, clean: boolean = false) {
|
||||
if (clean) return this.clearThumbar();
|
||||
this._win.setThumbarButtons([this._prev, playing ? this._pause : this._play, this._next]);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
} from "electron";
|
||||
import { isWin, isLinux, isDev, appName } from "./utils";
|
||||
import { isWin, appName } from "./utils";
|
||||
import { join } from "path";
|
||||
import log from "./logger";
|
||||
|
||||
@@ -269,12 +269,8 @@ class CreateTray implements MainTray {
|
||||
|
||||
export const initTray = (win: BrowserWindow, lyricWin: BrowserWindow) => {
|
||||
try {
|
||||
// 若为 MacOS
|
||||
if (isWin || isLinux || isDev) {
|
||||
log.info("🚀 Tray Process Startup");
|
||||
return new CreateTray(win, lyricWin);
|
||||
}
|
||||
return null;
|
||||
log.info("🚀 Tray Process Startup");
|
||||
return new CreateTray(win, lyricWin);
|
||||
} catch (error) {
|
||||
log.error("❌ Tray Process Error", error);
|
||||
return null;
|
||||
|
||||
@@ -46,7 +46,7 @@ const initAppServer = async () => {
|
||||
server.register(initNcmAPI, { prefix: "/api" });
|
||||
server.register(initUnblockAPI, { prefix: "/api" });
|
||||
// 启动端口
|
||||
const port = Number(import.meta.env["VITE_SERVER_PORT"] || 25884);
|
||||
const port = Number(process.env["VITE_SERVER_PORT"] || 25884);
|
||||
await server.listen({ port });
|
||||
log.info(`🌐 Starting AppServer on port ${port}`);
|
||||
return server;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { pathCase } from "change-case";
|
||||
import NeteaseCloudMusicApi from "NeteaseCloudMusicApi";
|
||||
import NeteaseCloudMusicApi from "@neteaseapireborn/api";
|
||||
import log from "../../main/logger";
|
||||
|
||||
// 获取数据
|
||||
@@ -33,12 +33,12 @@ const initNcmAPI = async (fastify: FastifyInstance) => {
|
||||
// 主信息
|
||||
fastify.get("/netease", (_, reply) => {
|
||||
reply.send({
|
||||
name: "NeteaseCloudMusicApi",
|
||||
version: "4.20.0",
|
||||
description: "网易云音乐 Node.js API service",
|
||||
author: "@binaryify",
|
||||
name: "@neteaseapireborn/api",
|
||||
version: "4.29.2",
|
||||
description: "网易云音乐 API Enhanced",
|
||||
author: "@MoeFurina",
|
||||
license: "MIT",
|
||||
url: "https://gitlab.com/Binaryify/neteasecloudmusicapi",
|
||||
url: "https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
14
electron/server/port.ts
Normal file
14
electron/server/port.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import getPort from "get-port";
|
||||
|
||||
// 默认端口
|
||||
let webPort: number;
|
||||
let servePort: number;
|
||||
|
||||
const getSafePort = async () => {
|
||||
if (webPort && servePort) return { webPort, servePort };
|
||||
webPort = await getPort({ port: 14558 });
|
||||
servePort = await getPort({ port: 25884 });
|
||||
return { webPort, servePort };
|
||||
};
|
||||
|
||||
export default getSafePort;
|
||||
69
eslint.config.mjs
Normal file
69
eslint.config.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import vue from "eslint-plugin-vue";
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import path from "node:path";
|
||||
import autoEslint from "./auto-eslint.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"**/node_modules",
|
||||
"**/dist",
|
||||
"**/out",
|
||||
"**/.gitignore",
|
||||
"**/auto-imports.d.ts",
|
||||
"**/components.d.ts",
|
||||
],
|
||||
},
|
||||
...compat.extends(
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:vue/vue3-essential",
|
||||
),
|
||||
{
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
vue,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...autoEslint.globals,
|
||||
},
|
||||
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
|
||||
parserOptions: {
|
||||
parser: "@typescript-eslint/parser",
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/.eslintrc.{js,cjs}"],
|
||||
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
ecmaVersion: 5,
|
||||
sourceType: "commonjs",
|
||||
},
|
||||
},
|
||||
];
|
||||
22
nginx.conf
22
nginx.conf
@@ -15,6 +15,22 @@ server {
|
||||
rewrite ^(.*)$ /index.html last;
|
||||
}
|
||||
|
||||
location /api/netease/song/url/v1 {
|
||||
proxy_buffers 16 64k;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Host $remote_addr;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
proxy_pass http://localhost:3000/song/url/v1;
|
||||
|
||||
sub_filter '"url":"https://music.163.com' '"url":"/music/unblock';
|
||||
sub_filter_types application/json;
|
||||
sub_filter_once off;
|
||||
}
|
||||
|
||||
location /api/netease/ {
|
||||
proxy_buffers 16 64k;
|
||||
proxy_buffer_size 128k;
|
||||
@@ -26,4 +42,10 @@ server {
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
proxy_pass http://localhost:3000/;
|
||||
}
|
||||
|
||||
location /music/unblock/ {
|
||||
proxy_pass https://music.163.com/;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
114
package.json
114
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "splayer",
|
||||
"productName": "SPlayer",
|
||||
"version": "3.0.0-alpha.2",
|
||||
"version": "3.0.0-beta.2",
|
||||
"description": "A minimalist music player",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "imsyy",
|
||||
@@ -34,81 +34,97 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@applemusic-like-lyrics/core": "^0.1.3",
|
||||
"@applemusic-like-lyrics/lyric": "^0.2.2",
|
||||
"@applemusic-like-lyrics/lyric": "^0.2.4",
|
||||
"@applemusic-like-lyrics/vue": "^0.1.5",
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@imsyy/color-utils": "^1.0.2",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@pixi/app": "^7.4.2",
|
||||
"@pixi/core": "^7.4.2",
|
||||
"@pixi/display": "^7.4.2",
|
||||
"@pixi/filter-blur": "^7.4.2",
|
||||
"@neteaseapireborn/api": "^4.29.2",
|
||||
"@pixi/app": "^7.4.3",
|
||||
"@pixi/core": "^7.4.3",
|
||||
"@pixi/display": "^7.4.3",
|
||||
"@pixi/filter-blur": "^7.4.3",
|
||||
"@pixi/filter-bulge-pinch": "^5.1.1",
|
||||
"@pixi/filter-color-matrix": "^7.4.2",
|
||||
"@pixi/sprite": "^7.4.2",
|
||||
"@vueuse/core": "^10.11.1",
|
||||
"NeteaseCloudMusicApi": "^4.22.0",
|
||||
"axios": "^1.7.7",
|
||||
"@pixi/filter-color-matrix": "^7.4.3",
|
||||
"@pixi/sprite": "^7.4.3",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"axios": "^1.11.0",
|
||||
"change-case": "^5.4.4",
|
||||
"dayjs": "^1.11.13",
|
||||
"electron-dl": "^3.5.2",
|
||||
"electron-dl": "^4.0.0",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.3.4",
|
||||
"electron-updater": "^6.6.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"font-list": "^1.5.1",
|
||||
"github-markdown-css": "^5.7.0",
|
||||
"get-port": "^7.1.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"howler": "^2.2.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jss": "^10.10.0",
|
||||
"jss-preset-default": "^10.10.0",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^14.1.2",
|
||||
"music-metadata": "7.14.0",
|
||||
"pinia": "^2.2.2",
|
||||
"pinia-plugin-persistedstate": "^3.2.3",
|
||||
"marked": "^15.0.8",
|
||||
"md5": "^2.3.0",
|
||||
"music-metadata": "10.5.1",
|
||||
"pinia": "^2.3.1",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"plyr": "^3.7.8",
|
||||
"vue-virtual-scroller": "2.0.0-beta.8"
|
||||
"vue-virt-list": "^1.5.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
"@fastify/http-proxy": "^9.5.0",
|
||||
"@fastify/multipart": "^8.3.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/http-proxy": "^11.1.2",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/howler": "^2.2.11",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-vue": "^5.1.3",
|
||||
"@types/md5": "^2.3.5",
|
||||
"@types/node": "^22.14.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.30.1",
|
||||
"@typescript-eslint/parser": "^8.30.1",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"ajv": "^8.17.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"electron": "^28.3.3",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-log": "^5.2.0",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fastify": "^4.28.1",
|
||||
"naive-ui": "^2.39.0",
|
||||
"node-taglib-sharp": "^5.2.3",
|
||||
"prettier": "^3.3.3",
|
||||
"sass": "^1.78.0",
|
||||
"terser": "^5.33.0",
|
||||
"typescript": "^5.5.4",
|
||||
"unplugin-auto-import": "^0.18.2",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vite": "^5.4.3",
|
||||
"electron": "^35.1.5",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-log": "^5.3.4",
|
||||
"electron-vite": "^3.1.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.3.1",
|
||||
"naive-ui": "^2.42.0",
|
||||
"node-taglib-sharp": "^6.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
"sass": "^1.86.3",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.8.3",
|
||||
"unplugin-auto-import": "^0.19.0",
|
||||
"unplugin-vue-components": "^28.5.0",
|
||||
"vite": "^5.4.18",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vue": "3.4.38",
|
||||
"vue-router": "^4.4.3",
|
||||
"vue-tsc": "^2.1.6"
|
||||
"vite-plugin-wasm": "^3.4.1",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-tsc": "^3.0.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"dmg-builder": "25.1.8",
|
||||
"electron-builder-squirrel-windows": "25.1.8"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
"core-js",
|
||||
"electron",
|
||||
"esbuild",
|
||||
"vue-demi"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
5994
pnpm-lock.yaml
generated
5994
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/icons/logo-icon.png
Normal file
BIN
public/icons/logo-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -55,9 +55,10 @@
|
||||
<!-- 路由页面 -->
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition :name="`router-${settingStore.routeAnimation}`" mode="out-in">
|
||||
<KeepAlive :max="20" :exclude="['layout']">
|
||||
<KeepAlive v-if="settingStore.useKeepAlive" :max="20" :exclude="['layout']">
|
||||
<component :is="Component" class="router-view" />
|
||||
</KeepAlive>
|
||||
<component v-else :is="Component" class="router-view" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<!-- 回顶 -->
|
||||
|
||||
@@ -57,3 +57,40 @@ export const uploadCloudSong = (file: File) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 云盘导入歌曲
|
||||
* @param {number} id - 歌曲 id
|
||||
* @param {string} song - 歌曲名称
|
||||
* @param {string} fileType - 歌曲格式
|
||||
* @param {number} fileSize - 歌曲大小
|
||||
* @param {number} bitrate - 歌曲比特率
|
||||
* @param {string} md5 - 歌曲 md5
|
||||
* @param {string} artist - 歌手
|
||||
* @param {string} album - 专辑
|
||||
*/
|
||||
export const importCloudSong = (
|
||||
song: string,
|
||||
fileType: string,
|
||||
fileSize: number,
|
||||
bitrate: number,
|
||||
md5: string,
|
||||
id?: number,
|
||||
artist?: string,
|
||||
album?: string,
|
||||
) => {
|
||||
return request({
|
||||
url: "/cloud/import",
|
||||
method: "POST",
|
||||
params: {id,
|
||||
song,
|
||||
fileType,
|
||||
fileSize,
|
||||
bitrate,
|
||||
md5,
|
||||
artist,
|
||||
album,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -109,3 +109,25 @@ export const matchSong = (
|
||||
params: { title, artist, album, duration, md5 },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 歌曲动态封面
|
||||
* @param {number} id - 歌曲 id
|
||||
*/
|
||||
export const songDynamicCover = (id: number) => {
|
||||
return request({
|
||||
url: "/song/dynamic/cover",
|
||||
params: { id },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 副歌时间
|
||||
* @param {number} id - 歌曲 id
|
||||
*/
|
||||
export const songChorus = (id: number) => {
|
||||
return request({
|
||||
url: "/song/chorus",
|
||||
params: { id },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
// 获取账号详情
|
||||
/**
|
||||
* 获取用户账号信息
|
||||
*/
|
||||
export const userAccount = () => {
|
||||
return request({
|
||||
url: "/user/account",
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
"id": 3136952023,
|
||||
"name": "私人雷达"
|
||||
},
|
||||
{
|
||||
"id": 8402996200,
|
||||
"name": "会员雷达"
|
||||
},
|
||||
{
|
||||
"id": 5320167908,
|
||||
"name": "时光雷达"
|
||||
|
||||
1
src/assets/icons/Help.svg
Normal file
1
src/assets/icons/Help.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M11.95 18q.525 0 .888-.363t.362-.887t-.362-.888t-.888-.362t-.887.363t-.363.887t.363.888t.887.362m.05 4q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m.1-14.3q.625 0 1.088.4t.462 1q0 .55-.337.975t-.763.8q-.575.5-1.012 1.1t-.438 1.35q0 .35.263.588t.612.237q.375 0 .638-.25t.337-.625q.1-.525.45-.937t.75-.788q.575-.55.988-1.2t.412-1.45q0-1.275-1.037-2.087T12.1 6q-.95 0-1.812.4T8.975 7.625q-.175.3-.112.638t.337.512q.35.2.725.125t.625-.425q.275-.375.688-.575t.862-.2"/></svg>
|
||||
|
After Width: | Height: | Size: 699 B |
437
src/components/Card/SongCard.vue
Normal file
437
src/components/Card/SongCard.vue
Normal file
@@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<div class="song-card">
|
||||
<div :class="['song-content', { play: musicStore.playSong.id === song.id }]">
|
||||
<!-- 序号 -->
|
||||
<div class="num" @dblclick.stop>
|
||||
<n-text v-if="musicStore.playSong.id !== song.id" depth="3">
|
||||
{{ index + 1 }}
|
||||
</n-text>
|
||||
<SvgIcon v-else :size="22" name="Music" />
|
||||
<!-- 播放暂停 -->
|
||||
<SvgIcon
|
||||
:size="28"
|
||||
:name="statusStore.playStatus ? 'Pause' : 'Play'"
|
||||
class="status"
|
||||
@click="player.playOrPause()"
|
||||
/>
|
||||
<!-- 播放 -->
|
||||
<SvgIcon :size="28" name="Play" class="play" @click="player.addNextSong(song, true)" />
|
||||
</div>
|
||||
<!-- 标题 -->
|
||||
<div class="title">
|
||||
<!-- 封面 -->
|
||||
<s-image
|
||||
v-if="!hiddenCover"
|
||||
:key="song.cover"
|
||||
:src="song.path ? song.cover : song.coverSize?.s || song.cover"
|
||||
class="cover"
|
||||
@update:show="localCover"
|
||||
/>
|
||||
<!-- 信息 -->
|
||||
<div class="info">
|
||||
<!-- 名称 -->
|
||||
<div class="name">
|
||||
<n-ellipsis
|
||||
:line-clamp="1"
|
||||
:tooltip="{
|
||||
placement: 'top',
|
||||
width: 'trigger',
|
||||
}"
|
||||
class="name-text"
|
||||
>
|
||||
{{ song?.name || "未知曲目" }}
|
||||
</n-ellipsis>
|
||||
<!-- 音质 -->
|
||||
<n-tag
|
||||
v-if="song?.path && song?.quality"
|
||||
:bordered="false"
|
||||
:type="song.quality === 'Hi-Res' ? 'warning' : 'info'"
|
||||
class="quality"
|
||||
round
|
||||
>
|
||||
{{ song.quality }}
|
||||
</n-tag>
|
||||
<!-- 特权 -->
|
||||
<n-tag v-if="song.originCoverType === 1" :bordered="false" type="primary" round>
|
||||
原
|
||||
</n-tag>
|
||||
<n-tag v-if="song.free === 1" :bordered="false" type="error" round> VIP </n-tag>
|
||||
<n-tag v-if="song.free === 4" :bordered="false" type="error" round> EP </n-tag>
|
||||
<!-- 云盘 -->
|
||||
<n-tag v-if="song?.pc" :bordered="false" class="cloud" type="info" round>
|
||||
<template #icon>
|
||||
<SvgIcon name="Cloud" />
|
||||
</template>
|
||||
</n-tag>
|
||||
<!-- MV -->
|
||||
<n-tag
|
||||
v-if="song?.mv"
|
||||
:bordered="false"
|
||||
class="mv"
|
||||
type="warning"
|
||||
round
|
||||
@click.stop="
|
||||
router.push({
|
||||
name: 'video',
|
||||
query: { id: song.mv },
|
||||
})
|
||||
"
|
||||
>
|
||||
MV
|
||||
</n-tag>
|
||||
</div>
|
||||
<!-- 歌手 -->
|
||||
<div v-if="Array.isArray(song.artists)" class="artists text-hidden">
|
||||
<n-text
|
||||
v-for="ar in song.artists"
|
||||
:key="ar.id"
|
||||
class="ar"
|
||||
@click="openJumpArtist(song.artists)"
|
||||
>
|
||||
{{ ar.name }}
|
||||
</n-text>
|
||||
</div>
|
||||
<div v-else-if="song.type === 'radio'" class="artists">
|
||||
<n-text class="ar"> 电台节目 </n-text>
|
||||
</div>
|
||||
<div v-else class="artists text-hidden" @click="openJumpArtist(song.artists)">
|
||||
<n-text class="ar"> {{ song.artists || "未知艺术家" }} </n-text>
|
||||
</div>
|
||||
<!-- 别名 -->
|
||||
<n-text v-if="song.alia" class="alia text-hidden" depth="3">{{ song.alia }}</n-text>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 专辑 -->
|
||||
<div v-if="song.type !== 'radio' && !hiddenAlbum" class="album text-hidden">
|
||||
<n-text
|
||||
v-if="isObject(song.album)"
|
||||
class="album-text"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'album',
|
||||
query: { id: song.album?.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ song.album?.name || "未知专辑" }}
|
||||
</n-text>
|
||||
<n-text v-else class="album-text">
|
||||
{{ song.album || "未知专辑" }}
|
||||
</n-text>
|
||||
</div>
|
||||
<!-- 操作 -->
|
||||
<div v-if="song.type !== 'radio'" class="actions" @click.stop @dblclick.stop>
|
||||
<!-- 喜欢歌曲 -->
|
||||
<SvgIcon
|
||||
:name="dataStore.isLikeSong(song.id) ? 'Favorite' : 'FavoriteBorder'"
|
||||
:size="20"
|
||||
@click.stop="toLikeSong(song, !dataStore.isLikeSong(song.id))"
|
||||
@delclick.stop
|
||||
/>
|
||||
</div>
|
||||
<!-- 更新日期 -->
|
||||
<n-text v-if="song.type === 'radio'" class="meta date" depth="3">
|
||||
{{ formatTimestamp(song.updateTime) }}
|
||||
</n-text>
|
||||
<!-- 播放量 -->
|
||||
<n-text v-if="song.type === 'radio'" class="meta" depth="3">
|
||||
{{ formatNumber(song.playCount || 0) }}
|
||||
</n-text>
|
||||
<!-- 时长 -->
|
||||
<n-text class="meta" depth="3">{{ msToTime(song.duration) }}</n-text>
|
||||
<!-- 大小 -->
|
||||
<n-text v-if="song.path && song.size && !hiddenSize" class="meta size" depth="3">
|
||||
{{ song.size }}M
|
||||
</n-text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SongType } from "@/types/main";
|
||||
import { useStatusStore, useMusicStore, useDataStore } from "@/stores";
|
||||
import { formatNumber, isElectron } from "@/utils/helper";
|
||||
import { openJumpArtist } from "@/utils/modal";
|
||||
import { toLikeSong } from "@/utils/auth";
|
||||
import { isObject } from "lodash-es";
|
||||
import { formatTimestamp, msToTime } from "@/utils/time";
|
||||
import player from "@/utils/player";
|
||||
import blob from "@/utils/blob";
|
||||
|
||||
const props = defineProps<{
|
||||
// 歌曲
|
||||
song: SongType;
|
||||
// 索引
|
||||
index: number;
|
||||
// 隐藏信息
|
||||
hiddenCover?: boolean;
|
||||
hiddenAlbum?: boolean;
|
||||
hiddenSize?: boolean;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
// 歌曲数据
|
||||
const song = toRef(props, "song");
|
||||
|
||||
// 加载本地歌曲封面
|
||||
const localCover = async (show: boolean) => {
|
||||
if (!isElectron || !show || !song.value.path) return;
|
||||
if (song.value.cover || song.value.cover === "/images/song.jpg?assest") return;
|
||||
// 获取封面
|
||||
const coverData = await window.electron.ipcRenderer.invoke("get-music-cover", song.value.path);
|
||||
if (!coverData) return;
|
||||
const { data, format } = coverData;
|
||||
const blobURL = blob.createBlobURL(data, format, song.value.path);
|
||||
if (blobURL) song.value.cover = blobURL;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.song-card {
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
.song-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
flex: 1;
|
||||
border-radius: 12px;
|
||||
border: 2px solid rgba(var(--primary), 0.12);
|
||||
background-color: var(--surface-container-hex);
|
||||
// transition:
|
||||
// transform 0.1s,
|
||||
// background-color 0.3s var(--n-bezier),
|
||||
// border-color 0.3s var(--n-bezier);
|
||||
&.play {
|
||||
border-color: rgba(var(--primary), 0.58);
|
||||
background-color: rgba(var(--primary), 0.28);
|
||||
}
|
||||
// &:active {
|
||||
// transform: scale(0.99);
|
||||
// }
|
||||
&:hover {
|
||||
border-color: rgba(var(--primary), 0.58);
|
||||
.num {
|
||||
.n-text,
|
||||
.n-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
.play {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
&.play {
|
||||
.num {
|
||||
.play {
|
||||
display: none;
|
||||
}
|
||||
.status {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.num {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
font-weight: bold;
|
||||
margin-right: 12px;
|
||||
.n-icon {
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
transform 0.3s;
|
||||
:deep(.svg-container) {
|
||||
color: var(--primary-hex);
|
||||
}
|
||||
}
|
||||
.status,
|
||||
.play {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
transform 0.3s;
|
||||
&:active {
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 20px 4px 0;
|
||||
.cover {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
min-width: 50px;
|
||||
border-radius: 8px;
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.name {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
:deep(.name-text) {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.n-tag {
|
||||
--n-height: 20px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-right: 6px;
|
||||
pointer-events: none;
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.quality {
|
||||
font-size: 10px;
|
||||
}
|
||||
.cloud {
|
||||
padding: 0 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
:deep(.n-tag__icon) {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.n-icon {
|
||||
font-size: 12px;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
}
|
||||
.mv {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
.artists {
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
.ar {
|
||||
display: inline-flex;
|
||||
transition: opacity 0.3s;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
&::after {
|
||||
content: "/";
|
||||
margin: 0 4px;
|
||||
}
|
||||
&:last-child {
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
.alia {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
.sort {
|
||||
margin-left: 6px;
|
||||
&::after {
|
||||
content: " )";
|
||||
}
|
||||
&::before {
|
||||
content: "( ";
|
||||
}
|
||||
}
|
||||
}
|
||||
.album {
|
||||
flex: 1;
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
padding-right: 20px;
|
||||
&:hover {
|
||||
.album-text {
|
||||
color: var(--primary-hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
.n-icon {
|
||||
transition: transform 0.3s;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
.meta {
|
||||
width: 50px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
&.size {
|
||||
width: 60px;
|
||||
}
|
||||
&.date {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
&.header {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
.n-text {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.title {
|
||||
position: relative;
|
||||
padding: 0 20px 0 0;
|
||||
&.has-sort {
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
top: 0;
|
||||
left: -8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--primary), 0.08);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
&:hover {
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -199,6 +199,9 @@ const changeGlobalTheme = () => {
|
||||
railColor: `rgba(${colorSchemes.primary}, 0.2)`,
|
||||
railColorHover: `rgba(${colorSchemes.primary}, 0.3)`,
|
||||
},
|
||||
Popover: {
|
||||
color: `rgb(${colorSchemes["surface-container"]})`,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
themeOverrides.value = {};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<!-- 全局图标 -->
|
||||
<template>
|
||||
<n-icon v-if="name" :size="size" :color="color" :depth="depth" v-html="svgContent" />
|
||||
<n-icon v-if="name" :size="size" :color="color" :depth="depth">
|
||||
<div ref="svgContainer" class="svg-container" />
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -11,15 +13,17 @@ const props = defineProps<{
|
||||
depth?: 1 | 2 | 3 | 4 | 5;
|
||||
}>();
|
||||
|
||||
const svgContent = ref("");
|
||||
const svgContent = ref<string>("");
|
||||
const svgContainer = ref<HTMLElement | null>(null);
|
||||
|
||||
// 加载图标
|
||||
const loadSVG = async (name: string) => {
|
||||
try {
|
||||
const svg = await import(`../../assets/icons/${name}.svg?raw`);
|
||||
svgContent.value = svg.default || svg;
|
||||
if (svgContainer.value) svgContainer.value.innerHTML = svgContent.value;
|
||||
} catch (error) {
|
||||
console.error(`Could not load SVG for icon name: ${name}`);
|
||||
console.error(`Could not load SVG for icon name: ${name}`, error);
|
||||
svgContent.value = "";
|
||||
}
|
||||
};
|
||||
@@ -37,6 +41,12 @@ onMounted(() => loadSVG(props.name));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
// transition: all 0.3s;
|
||||
color: var(--primary-hex);
|
||||
// color: var(--primary-hex);
|
||||
.svg-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// color: var(--primary-hex);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,7 +52,7 @@ const updateScroll = () => {
|
||||
|
||||
// 滚动动画定时器
|
||||
let animationId: number | null = null;
|
||||
let scrollTimeoutId: NodeJS.Timeout | null = null;
|
||||
let scrollTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 开始滚动
|
||||
const startScrolling = () => {
|
||||
|
||||
@@ -32,8 +32,8 @@ import { useRouter, RouterLink } from "vue-router";
|
||||
import { isElectron, renderIcon } from "@/utils/helper";
|
||||
import { openCreatePlaylist } from "@/utils/modal";
|
||||
import { debounce } from "lodash-es";
|
||||
import player from "@/utils/player";
|
||||
import { isLogin } from "@/utils/auth";
|
||||
import player from "@/utils/player";
|
||||
|
||||
const router = useRouter();
|
||||
const dataStore = useDataStore();
|
||||
@@ -72,7 +72,7 @@ const menuOptions = computed<MenuOption[] | MenuGroupOption[]>(() => {
|
||||
{
|
||||
key: "personal-fm",
|
||||
label: "私人漫游",
|
||||
show: isLogin(),
|
||||
show: isLogin() !== 0,
|
||||
icon: renderIcon("Radio", {
|
||||
style: {
|
||||
transform: "translateY(-1px)",
|
||||
@@ -122,7 +122,7 @@ const menuOptions = computed<MenuOption[] | MenuGroupOption[]>(() => {
|
||||
key: "cloud",
|
||||
link: "cloud",
|
||||
label: "我的云盘",
|
||||
show: isElectron,
|
||||
show: isLogin() === 1,
|
||||
icon: renderIcon("Cloud"),
|
||||
},
|
||||
{
|
||||
@@ -176,11 +176,20 @@ const menuOptions = computed<MenuOption[] | MenuGroupOption[]>(() => {
|
||||
children: [...likedPlaylist.value],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
: [
|
||||
{
|
||||
key: "local",
|
||||
link: "local",
|
||||
label: "本地歌曲",
|
||||
show: isElectron,
|
||||
icon: renderIcon("FolderMusic"),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// 生成歌单列表
|
||||
const renderPlaylist = (playlist: CoverType[], showCover: boolean) => {
|
||||
if (!isLogin()) return [];
|
||||
return playlist.map((playlist) => ({
|
||||
key: playlist.id,
|
||||
label: () =>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</template>
|
||||
<div class="user-menu" @click="userMenuShow = false">
|
||||
<!-- 喜欢数量 -->
|
||||
<div class="like-num">
|
||||
<div v-if="dataStore.loginType !== 'uid'" class="like-num">
|
||||
<div
|
||||
v-for="(item, index) in userLikeData"
|
||||
:key="index"
|
||||
@@ -44,6 +44,10 @@
|
||||
<n-text :depth="3">{{ item.label }}</n-text>
|
||||
</div>
|
||||
</div>
|
||||
<n-flex v-else align="center" vertical>
|
||||
<n-text>UID 登录模式</n-text>
|
||||
<n-text :depth="3">部分功能暂不可用</n-text>
|
||||
</n-flex>
|
||||
<n-divider />
|
||||
<!-- 退出登录 -->
|
||||
<n-button :focusable="false" class="logout" strong secondary round @click="isLogout">
|
||||
@@ -60,7 +64,13 @@
|
||||
import { useDataStore } from "@/stores";
|
||||
import { openUserLogin } from "@/utils/modal";
|
||||
import { getLoginState } from "@/api/login";
|
||||
import { toLogout, isLogin, refreshLoginData, updateUserData } from "@/utils/auth";
|
||||
import {
|
||||
toLogout,
|
||||
isLogin,
|
||||
refreshLoginData,
|
||||
updateUserData,
|
||||
updateSpecialUserData,
|
||||
} from "@/utils/auth";
|
||||
|
||||
const router = useRouter();
|
||||
const dataStore = useDataStore();
|
||||
@@ -100,6 +110,11 @@ const userLikeData = computed(() => {
|
||||
|
||||
// 检查登录状态
|
||||
const checkLoginStatus = async () => {
|
||||
// 若为 UID 登录
|
||||
if (dataStore.loginType === "uid") {
|
||||
await updateSpecialUserData();
|
||||
return;
|
||||
}
|
||||
// 获取登录状态
|
||||
const loginState = await getLoginState();
|
||||
// 登录正常
|
||||
@@ -113,6 +128,7 @@ const checkLoginStatus = async () => {
|
||||
// 若还有用户数据,则登录过期
|
||||
else if (dataStore.userData.userId !== 0) {
|
||||
dataStore.userLoginStatus = false;
|
||||
dataStore.userData.userId = 0;
|
||||
window.$message.warning("登录已过期,请重新登录", { duration: 2000 });
|
||||
openUserLogin();
|
||||
}
|
||||
|
||||
@@ -15,25 +15,16 @@
|
||||
<div class="artist-item">
|
||||
<!-- 封面 -->
|
||||
<div class="cover">
|
||||
<n-image
|
||||
<s-image
|
||||
:src="item.coverSize?.m || item.cover"
|
||||
default-src="/images/artist.jpg?assest"
|
||||
class="cover-img"
|
||||
preview-disabled
|
||||
lazy
|
||||
@load="coverLoaded"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="cover-loading">
|
||||
<img src="/images/artist.jpg?assest" class="loading-img" alt="loading-img" />
|
||||
</div>
|
||||
</template>
|
||||
</n-image>
|
||||
/>
|
||||
<!-- 封面背板 -->
|
||||
<n-image
|
||||
class="cover-shadow"
|
||||
preview-disabled
|
||||
lazy
|
||||
<s-image
|
||||
:src="item.coverSize?.m || item.cover"
|
||||
default-src="/images/artist.jpg?assest"
|
||||
class="cover-shadow"
|
||||
/>
|
||||
<!-- 图标 -->
|
||||
<SvgIcon name="Artist" />
|
||||
@@ -78,7 +69,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ArtistType } from "@/types/main";
|
||||
import { coverLoaded } from "@/utils/helper";
|
||||
|
||||
interface Props {
|
||||
data: ArtistType[];
|
||||
@@ -124,15 +114,6 @@ const router = useRouter();
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 50%;
|
||||
transition: border-radius 0.3s;
|
||||
:deep(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
filter 0.3s,
|
||||
transform 0.3s;
|
||||
}
|
||||
.cover-img {
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
@@ -145,6 +126,7 @@ const router = useRouter();
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
top: 20%;
|
||||
width: 80%;
|
||||
height: auto;
|
||||
|
||||
@@ -10,32 +10,17 @@
|
||||
>
|
||||
<!-- 封面 -->
|
||||
<div class="cover">
|
||||
<n-image
|
||||
<s-image
|
||||
:key="item.cover"
|
||||
:src="
|
||||
type === 'video' ? `${item.cover}?param=464y260` : item.coverSize?.m || item.cover
|
||||
"
|
||||
:default-src="
|
||||
type !== 'video' ? '/images/album.jpg?assest' : '/images/video.jpg?assest'
|
||||
"
|
||||
class="cover-img"
|
||||
preview-disabled
|
||||
lazy
|
||||
@load="coverLoaded"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="cover-loading">
|
||||
<img
|
||||
v-if="type !== 'video'"
|
||||
src="/images/album.jpg?assest"
|
||||
class="loading-img"
|
||||
alt="loading-img"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="/images/video.jpg?assest"
|
||||
class="loading-img"
|
||||
alt="loading-img"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</n-image>
|
||||
once
|
||||
/>
|
||||
<template v-if="item.playCount">
|
||||
<!-- 遮罩 -->
|
||||
<div v-if="type !== 'album'" class="cover-mask" />
|
||||
@@ -129,7 +114,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { CoverType, SongType } from "@/types/main";
|
||||
import { albumDetail } from "@/api/album";
|
||||
import { coverLoaded, formatNumber } from "@/utils/helper";
|
||||
import { formatNumber } from "@/utils/helper";
|
||||
import { useMusicStore, useStatusStore } from "@/stores";
|
||||
import { debounce } from "lodash-es";
|
||||
import { formatSongsList } from "@/utils/format";
|
||||
@@ -255,7 +240,7 @@ const getListData = async (id: number): Promise<SongType[]> => {
|
||||
:deep(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
// opacity: 0;
|
||||
transition: opacity 0.35s ease-in-out;
|
||||
}
|
||||
.cover-img {
|
||||
@@ -378,7 +363,6 @@ const getListData = async (id: number): Promise<SongType[]> => {
|
||||
&:hover {
|
||||
background-color: rgba(var(--primary), 0.12);
|
||||
.cover {
|
||||
border-radius: 16px 16px 0 0;
|
||||
.cover-img {
|
||||
transform: scale(1.1);
|
||||
filter: brightness(0.8);
|
||||
|
||||
@@ -1,321 +1,181 @@
|
||||
<!-- 歌曲列表 - 虚拟列表 -->
|
||||
<!-- vue-virt-list https://github.com/keno-lee/vue-virt-list -->
|
||||
<template>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="data.length > 0"
|
||||
v-if="!isEmpty(listData)"
|
||||
ref="songListRef"
|
||||
:style="{ height: disableVirtualList ? undefined : '100%' }"
|
||||
:class="['song-list', { 'no-padding': hiddenPadding }]"
|
||||
:class="[
|
||||
'song-list',
|
||||
{
|
||||
'hidden-scrollbar': hiddenScrollbar,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<DynamicScroller
|
||||
ref="scrollerRef"
|
||||
:items="listData"
|
||||
:min-item-size="94"
|
||||
:emitUpdate="true"
|
||||
:style="{ height: disableVirtualList ? undefined : `${songListShowHeight}px` }"
|
||||
class="scroller"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<template #before>
|
||||
<slot name="header" />
|
||||
<div class="song-item header" :style="{ margin }">
|
||||
<n-text class="num">#</n-text>
|
||||
<n-dropdown
|
||||
v-if="!disabledSort"
|
||||
:options="sortMenuOptions"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
@select="sortSelect"
|
||||
>
|
||||
<div class="title has-sort">
|
||||
<n-text>标题</n-text>
|
||||
<n-text v-if="statusStore.listSort !== 'default'" class="sort" depth="3">
|
||||
{{ sortOptions[statusStore.listSort].name }}
|
||||
</n-text>
|
||||
</div>
|
||||
</n-dropdown>
|
||||
<n-text v-else class="title">标题</n-text>
|
||||
<n-text v-if="type !== 'radio' && !hiddenAlbum" class="album">专辑</n-text>
|
||||
<n-text v-if="type !== 'radio'" class="actions">操作</n-text>
|
||||
<n-text v-if="type === 'radio'" class="meta date">更新日期</n-text>
|
||||
<n-text v-if="type === 'radio'" class="meta">播放量</n-text>
|
||||
<n-text class="meta">时长</n-text>
|
||||
<n-text v-if="data?.[0].size && !hiddenSize" class="meta size">大小</n-text>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot="{ item, index, active }">
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:data-index="index"
|
||||
:key="item.id"
|
||||
class="song-item-wrapper"
|
||||
>
|
||||
<div
|
||||
:class="['song-item', { play: musicStore.playSong.id === item.id }]"
|
||||
:style="{ margin }"
|
||||
@dblclick.stop="playSong(item)"
|
||||
@contextmenu="
|
||||
songListMenuRef?.openDropdown($event, data, item, index, type, playListId)
|
||||
"
|
||||
>
|
||||
<!-- 序号 -->
|
||||
<div class="num" @dblclick.stop>
|
||||
<n-text v-if="musicStore.playSong.id !== item.id" depth="3">
|
||||
{{ index + 1 }}
|
||||
</n-text>
|
||||
<SvgIcon v-else :size="22" name="Music" />
|
||||
<!-- 播放暂停 -->
|
||||
<SvgIcon
|
||||
:size="28"
|
||||
:name="statusStore.playStatus ? 'Pause' : 'Play'"
|
||||
class="status"
|
||||
@click="player.playOrPause()"
|
||||
/>
|
||||
<!-- 播放 -->
|
||||
<SvgIcon
|
||||
:size="28"
|
||||
name="Play"
|
||||
class="play"
|
||||
@click="player.addNextSong(item, true)"
|
||||
/>
|
||||
</div>
|
||||
<!-- 标题 -->
|
||||
<div class="title">
|
||||
<!-- 封面 -->
|
||||
<n-image
|
||||
v-if="!hiddenCover"
|
||||
:key="item.cover"
|
||||
:src="item.path ? item.cover : item.coverSize?.s || item.cover"
|
||||
fallback-src="/images/song.jpg?assest"
|
||||
class="cover"
|
||||
preview-disabled
|
||||
v-visible="(show: boolean) => localCover(show, item?.path, index)"
|
||||
@load="coverLoaded"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="cover-loading">
|
||||
<img src="/images/song.jpg?assest" class="loading-img" alt="loading-img" />
|
||||
</div>
|
||||
</template>
|
||||
</n-image>
|
||||
<!-- 信息 -->
|
||||
<div class="info">
|
||||
<!-- 名称 -->
|
||||
<div class="name">
|
||||
<n-ellipsis
|
||||
:line-clamp="1"
|
||||
:tooltip="{
|
||||
placement: 'top',
|
||||
width: 'trigger',
|
||||
}"
|
||||
class="name-text"
|
||||
>
|
||||
{{ item?.name || "未知曲目" }}
|
||||
</n-ellipsis>
|
||||
<!-- 音质 -->
|
||||
<n-tag
|
||||
v-if="item?.path && item?.quality"
|
||||
:bordered="false"
|
||||
:type="item.quality === 'Hi-Res' ? 'warning' : 'info'"
|
||||
class="quality"
|
||||
round
|
||||
>
|
||||
{{ item.quality }}
|
||||
</n-tag>
|
||||
<!-- 特权 -->
|
||||
<n-tag v-if="item.originCoverType === 1" :bordered="false" type="primary" round>
|
||||
原
|
||||
</n-tag>
|
||||
<n-tag v-if="item.free === 1" :bordered="false" type="error" round> VIP </n-tag>
|
||||
<n-tag v-if="item.free === 4" :bordered="false" type="error" round> EP </n-tag>
|
||||
<!-- 云盘 -->
|
||||
<n-tag v-if="item?.pc" :bordered="false" class="cloud" type="info" round>
|
||||
<template #icon>
|
||||
<SvgIcon name="Cloud" />
|
||||
</template>
|
||||
</n-tag>
|
||||
<!-- MV -->
|
||||
<n-tag
|
||||
v-if="item?.mv"
|
||||
:bordered="false"
|
||||
class="mv"
|
||||
type="warning"
|
||||
round
|
||||
@click.stop="
|
||||
router.push({
|
||||
name: 'video',
|
||||
query: { id: item.mv },
|
||||
})
|
||||
"
|
||||
>
|
||||
MV
|
||||
</n-tag>
|
||||
</div>
|
||||
<!-- 歌手 -->
|
||||
<div v-if="Array.isArray(item.artists)" class="artists text-hidden">
|
||||
<n-text
|
||||
v-for="ar in item.artists"
|
||||
:key="ar.id"
|
||||
class="ar"
|
||||
@click="openJumpArtist(item.artists)"
|
||||
>
|
||||
{{ ar.name }}
|
||||
</n-text>
|
||||
</div>
|
||||
<div v-else-if="type === 'radio'" class="artists">
|
||||
<n-text class="ar"> 电台节目 </n-text>
|
||||
</div>
|
||||
<div v-else class="artists text-hidden" @click="openJumpArtist(item.artists)">
|
||||
<n-text class="ar"> {{ item.artists || "未知艺术家" }} </n-text>
|
||||
</div>
|
||||
<!-- 别名 -->
|
||||
<n-text v-if="item.alia" class="alia" depth="3">{{ item.alia }}</n-text>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<VirtList
|
||||
ref="listRef"
|
||||
:key="listData?.[0]?.id"
|
||||
:list="listData"
|
||||
:minSize="94"
|
||||
:buffer="2"
|
||||
:offset="offset"
|
||||
:style="{ height: height === 'auto' ? 'auto' : `${height || songListHeight}px` }"
|
||||
itemKey="id"
|
||||
@scroll="onScroll"
|
||||
@toBottom="onToBottom"
|
||||
>
|
||||
<!-- 悬浮顶栏 -->
|
||||
<template #stickyHeader>
|
||||
<div class="list-header song-card">
|
||||
<n-text class="num">#</n-text>
|
||||
<n-dropdown
|
||||
v-if="!disabledSort"
|
||||
:options="sortMenuOptions"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
@select="sortSelect"
|
||||
>
|
||||
<div class="title has-sort">
|
||||
<n-text>标题</n-text>
|
||||
<n-text v-if="statusStore.listSort !== 'default'" class="sort" depth="3">
|
||||
{{ sortOptions[statusStore.listSort].name }}
|
||||
</n-text>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 专辑 -->
|
||||
<div v-if="type !== 'radio' && !hiddenAlbum" class="album text-hidden">
|
||||
<n-text
|
||||
v-if="isObject(item.album)"
|
||||
class="album-text"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'album',
|
||||
query: { id: item.album?.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ item.album?.name || "未知专辑" }}
|
||||
</n-text>
|
||||
<n-text v-else class="album-text">
|
||||
{{ item.album || "未知专辑" }}
|
||||
</n-text>
|
||||
</div>
|
||||
<!-- 操作 -->
|
||||
<div v-if="type !== 'radio'" class="actions" @click.stop @dblclick.stop>
|
||||
<!-- 喜欢歌曲 -->
|
||||
<SvgIcon
|
||||
:name="dataStore.isLikeSong(item.id) ? 'Favorite' : 'FavoriteBorder'"
|
||||
:size="20"
|
||||
@click.stop="toLikeSong(item, !dataStore.isLikeSong(item.id))"
|
||||
@delclick.stop
|
||||
/>
|
||||
</div>
|
||||
<!-- 更新日期 -->
|
||||
<n-text v-if="type === 'radio'" class="meta date" depth="3">
|
||||
{{ formatTimestamp(item.updateTime) }}
|
||||
</n-text>
|
||||
<!-- 播放量 -->
|
||||
<n-text v-if="type === 'radio'" class="meta" depth="3">
|
||||
{{ formatNumber(item.playCount) }}
|
||||
</n-text>
|
||||
<!-- 时长 -->
|
||||
<n-text class="meta" depth="3">{{ msToTime(item.duration) }}</n-text>
|
||||
<!-- 大小 -->
|
||||
<n-text v-if="item.path && item.size && !hiddenSize" class="meta size" depth="3">
|
||||
{{ item.size }}M
|
||||
</n-text>
|
||||
</n-dropdown>
|
||||
<n-text v-else class="title">标题</n-text>
|
||||
<n-text v-if="type !== 'radio' && !hiddenAlbum" class="album">专辑</n-text>
|
||||
<n-text v-if="type !== 'radio'" class="actions">操作</n-text>
|
||||
<n-text v-if="type === 'radio'" class="meta date">更新日期</n-text>
|
||||
<n-text v-if="type === 'radio'" class="meta">播放量</n-text>
|
||||
<n-text class="meta">时长</n-text>
|
||||
<n-text v-if="data?.[0].size && !hiddenSize" class="meta size">大小</n-text>
|
||||
</div>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
<template #after>
|
||||
<div class="list-after">
|
||||
<n-flex v-if="loadMore && loading">
|
||||
<n-spin size="small" />
|
||||
<n-text>{{ loadingText || "努力加载中" }}</n-text>
|
||||
</n-flex>
|
||||
<n-divider v-else dashed> 没有更多啦 ~ </n-divider>
|
||||
</div>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
</template>
|
||||
<!-- 主内容 -->
|
||||
<template #default="{ itemData, index }">
|
||||
<SongCard
|
||||
:song="itemData"
|
||||
:index="index"
|
||||
:hiddenCover="hiddenCover"
|
||||
:hiddenAlbum="hiddenAlbum"
|
||||
:hiddenSize="hiddenSize"
|
||||
@dblclick.stop="player.updatePlayList(listData, itemData, playListId)"
|
||||
@contextmenu.stop="
|
||||
songListMenuRef?.openDropdown($event, listData, itemData, index, type, playListId)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<!-- 加载更多 -->
|
||||
<template #footer>
|
||||
<div class="load-more">
|
||||
<n-flex v-if="loadMore && loading">
|
||||
<n-spin size="small" />
|
||||
<n-text>{{ loadingText || "努力加载中" }}</n-text>
|
||||
</n-flex>
|
||||
<n-divider v-else dashed> 没有更多啦 ~ </n-divider>
|
||||
</div>
|
||||
</template>
|
||||
</VirtList>
|
||||
</Transition>
|
||||
<!-- 右键菜单 -->
|
||||
<SongListMenu ref="songListMenuRef" @removeSong="removeSong" />
|
||||
<!-- 列表操作 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<n-float-button-group v-if="floatToolShow && !disableVirtualList" class="list-button">
|
||||
<n-float-button-group v-if="floatToolShow" class="list-menu">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<n-float-button v-if="songListScrollTop > 100" width="42" @click="scrollTo(0)">
|
||||
<n-float-button v-if="scrollTop > 100" width="42" @click="listRef?.scrollToTop()">
|
||||
<SvgIcon :size="22" name="Up" />
|
||||
</n-float-button>
|
||||
</Transition>
|
||||
<n-float-button v-if="hasPlaySong >= 0" width="42" @click="scrollTo(hasPlaySong)">
|
||||
<n-float-button
|
||||
v-if="hasPlaySong >= 0"
|
||||
width="42"
|
||||
@click="listRef?.scrollToIndex(hasPlaySong)"
|
||||
>
|
||||
<SvgIcon :size="22" name="Location" />
|
||||
</n-float-button>
|
||||
</n-float-button-group>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
<!-- 加载动画 -->
|
||||
<!-- 列表加载 - 骨架屏 -->
|
||||
<div v-else-if="loading" class="song-list loading">
|
||||
<n-skeleton :repeat="10" text />
|
||||
</div>
|
||||
<!-- 空列表 -->
|
||||
<n-empty v-else description="空空如也,怎么一首歌都没有啊" size="large" class="song-list" />
|
||||
<n-empty v-else description="列表光秃秃的,啥都没有哦" size="large" class="song-list empty" />
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SongType, SortType } from "@/types/main";
|
||||
import type { DropdownOption } from "naive-ui";
|
||||
import { useStatusStore, useMusicStore, useDataStore } from "@/stores";
|
||||
import { isObject, entries, cloneDeep } from "lodash-es";
|
||||
import { openJumpArtist } from "@/utils/modal";
|
||||
import { formatNumber, isElectron } from "@/utils/helper";
|
||||
import type { SongType, SortType } from "@/types/main";
|
||||
import { useMusicStore, useStatusStore } from "@/stores";
|
||||
import { VirtList } from "vue-virt-list";
|
||||
import { cloneDeep, entries, isEmpty } from "lodash-es";
|
||||
import { sortOptions } from "@/utils/meta";
|
||||
import { toLikeSong } from "@/utils/auth";
|
||||
import { formatTimestamp, msToTime } from "@/utils/time";
|
||||
import { renderIcon } from "@/utils/helper";
|
||||
import SongListMenu from "@/components/Menu/SongListMenu.vue";
|
||||
import player from "@/utils/player";
|
||||
import blob from "@/utils/blob";
|
||||
|
||||
const router = useRouter();
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
interface Props {
|
||||
data: SongType[];
|
||||
type?: "song" | "radio";
|
||||
loadMore?: boolean;
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
hiddenAlbum?: boolean;
|
||||
hiddenCover?: boolean;
|
||||
hiddenPadding?: boolean;
|
||||
hiddenSize?: boolean;
|
||||
margin?: string;
|
||||
height?: number;
|
||||
// 禁用排序
|
||||
disabledSort?: boolean;
|
||||
// 播放歌单 ID
|
||||
playListId?: number;
|
||||
// 禁用虚拟列表
|
||||
disableVirtualList?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: "song",
|
||||
});
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
// 列表数据
|
||||
data: SongType[];
|
||||
// 列表类型
|
||||
type?: "song" | "radio";
|
||||
// 列表高度
|
||||
height?: number | "auto"; // px
|
||||
// 是否加载
|
||||
loading?: boolean;
|
||||
// 加载更多
|
||||
loadMore?: boolean;
|
||||
loadingText?: string;
|
||||
// 隐藏元素
|
||||
hiddenAlbum?: boolean;
|
||||
hiddenCover?: boolean;
|
||||
hiddenSize?: boolean;
|
||||
// 隐藏滚动条
|
||||
hiddenScrollbar?: boolean;
|
||||
// 禁用排序
|
||||
disabledSort?: boolean;
|
||||
// 播放歌单 ID
|
||||
playListId?: number;
|
||||
}>(),
|
||||
{
|
||||
type: "song",
|
||||
loadingText: "努力加载中...",
|
||||
playListId: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
// 触底
|
||||
reachBottom: [];
|
||||
reachBottom: [e: Event];
|
||||
// 滚动
|
||||
scroll: [e: Event];
|
||||
// 删除歌曲
|
||||
removeSong: [id: number[]];
|
||||
}>();
|
||||
|
||||
// 右键菜单
|
||||
const songListMenuRef = ref<InstanceType<typeof SongListMenu> | null>(null);
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
// 列表状态
|
||||
const offset = ref<number>(0);
|
||||
const scrollTop = ref<number>(0);
|
||||
|
||||
// 列表元素
|
||||
const scrollerRef = ref<any | null>(null);
|
||||
const listRef = ref<InstanceType<typeof VirtList> | null>(null);
|
||||
const songListRef = ref<HTMLElement | null>(null);
|
||||
const songListScrollTop = ref<number>(0);
|
||||
|
||||
// 悬浮工具
|
||||
const floatToolShow = ref<boolean>(false);
|
||||
const floatToolShow = ref<boolean>(true);
|
||||
|
||||
// 右键菜单
|
||||
const songListMenuRef = ref<InstanceType<typeof SongListMenu> | null>(null);
|
||||
|
||||
// 列表数据
|
||||
const listData = computed<SongType[]>(() => {
|
||||
@@ -352,27 +212,36 @@ const listData = computed<SongType[]>(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// 列表元素高度
|
||||
const { height: songListHeight, stop: stopHeight } = useElementSize(songListRef);
|
||||
|
||||
// 应该展示的列表高度
|
||||
const songListShowHeight = computed(() => props.height ?? songListHeight.value);
|
||||
|
||||
// 列表是否具有播放歌曲
|
||||
const hasPlaySong = computed(() => {
|
||||
return listData.value.findIndex((item) => item.id === musicStore.playSong.id);
|
||||
});
|
||||
|
||||
// 列表元素高度
|
||||
const { height: songListHeight, stop: stopCalcHeight } = useElementSize(songListRef);
|
||||
|
||||
// 列表排序菜单
|
||||
const sortMenuOptions = computed<DropdownOption[]>(() =>
|
||||
entries(sortOptions).map(([key, { name, show, icon }]) => ({
|
||||
key,
|
||||
label: name,
|
||||
show: show === "all" ? true : show === props.type ? true : false,
|
||||
icon,
|
||||
icon: renderIcon(icon),
|
||||
})),
|
||||
);
|
||||
|
||||
// 列表滚动
|
||||
const onScroll = (e: Event) => {
|
||||
emit("scroll", e);
|
||||
scrollTop.value = (e.target as HTMLElement).scrollTop;
|
||||
};
|
||||
|
||||
// 列表触底
|
||||
const onToBottom = (e: Event) => {
|
||||
if (props.loading) return;
|
||||
emit("reachBottom", e);
|
||||
};
|
||||
|
||||
// 排序更改
|
||||
const sortSelect = (key: SortType) => {
|
||||
statusStore.listSort = key;
|
||||
@@ -384,110 +253,55 @@ const sortSelect = (key: SortType) => {
|
||||
scrobble: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 滚动至播放歌曲
|
||||
const scrollTo = (index: number) => {
|
||||
if (index === 0) songListScrollTop.value = 0;
|
||||
scrollerRef.value?.scrollToItem(index);
|
||||
};
|
||||
|
||||
// 封面加载完成
|
||||
const coverLoaded = (e: Event) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && target.nodeType === Node.ELEMENT_NODE) {
|
||||
target.style.opacity = "1";
|
||||
}
|
||||
};
|
||||
|
||||
// 加载本地歌曲封面
|
||||
const localCover = async (show: boolean, path: string, index: number) => {
|
||||
if (!isElectron || !show || !path) return;
|
||||
if (listData.value[index].cover || listData.value[index].cover === "/images/song.jpg?assest")
|
||||
return;
|
||||
// 获取封面
|
||||
const coverData = await window.electron.ipcRenderer.invoke("get-music-cover", path);
|
||||
if (!coverData) return;
|
||||
const { data, format } = coverData;
|
||||
const blobURL = blob.createBlobURL(data, format, path);
|
||||
if (blobURL) listData.value[index].cover = blobURL;
|
||||
};
|
||||
|
||||
// 播放列表歌曲
|
||||
const playSong = (song: SongType) => {
|
||||
console.log(song);
|
||||
// 更改播放列表
|
||||
player.updatePlayList(listData.value, song, props.playListId);
|
||||
};
|
||||
|
||||
// 列表滚动
|
||||
const onScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
// 获取高度
|
||||
if (target && target.scrollTop) {
|
||||
songListScrollTop.value = target.scrollTop;
|
||||
}
|
||||
// 是否触底
|
||||
const offset: number = 300;
|
||||
if (target && target.scrollTop + target.clientHeight >= target.scrollHeight - offset) {
|
||||
if (props.loadMore && !props.loading) emit("reachBottom");
|
||||
}
|
||||
// 滚动事件
|
||||
emit("scroll", e);
|
||||
// 滚动到顶部
|
||||
listRef.value?.scrollToIndex(hasPlaySong.value || 0);
|
||||
};
|
||||
|
||||
// 删除指定索引
|
||||
const removeSong = (id: number[]) => emit("removeSong", id);
|
||||
|
||||
onDeactivated(() => {
|
||||
// keep-alive 处理
|
||||
onBeforeRouteLeave(() => {
|
||||
offset.value = listRef.value?.getOffset() || 0;
|
||||
floatToolShow.value = false;
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
floatToolShow.value = true;
|
||||
if (props.disableVirtualList) stopHeight();
|
||||
if (props.height === "auto") stopCalcHeight();
|
||||
if (offset.value > 0) listRef.value?.scrollToOffset(offset.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopCalcHeight();
|
||||
floatToolShow.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.song-list {
|
||||
.scroller {
|
||||
padding-bottom: 14px;
|
||||
transition: height 0.3s;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
&::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(var(--primary), 0.28);
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
.song-item-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 94px;
|
||||
height: 100%;
|
||||
border-radius: 12px 0 0 12px;
|
||||
overflow: hidden;
|
||||
.song-card {
|
||||
padding-bottom: 12px;
|
||||
// padding-right: 4px;
|
||||
}
|
||||
.song-item {
|
||||
// 悬浮顶栏
|
||||
.list-header {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
// min-height: 70px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 2px solid rgba(var(--primary), 0.12);
|
||||
background-color: var(--surface-container-hex);
|
||||
transition:
|
||||
background-color 0.3s var(--n-bezier),
|
||||
border-color 0.3s var(--n-bezier);
|
||||
cursor: pointer;
|
||||
// margin-right: 4px;
|
||||
border: 1px solid transparent;
|
||||
background-color: var(--background-hex);
|
||||
.n-text {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.num {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -497,112 +311,14 @@ onActivated(() => {
|
||||
min-width: 40px;
|
||||
font-weight: bold;
|
||||
margin-right: 12px;
|
||||
.n-icon {
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
transform 0.3s;
|
||||
}
|
||||
.status,
|
||||
.play {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
&:active {
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.title {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 20px 4px 0;
|
||||
.cover {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
min-width: 50px;
|
||||
border-radius: 8px;
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
:deep(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease-in-out;
|
||||
}
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.name {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
:deep(.name-text) {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.n-tag {
|
||||
--n-height: 20px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-right: 6px;
|
||||
pointer-events: none;
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.quality {
|
||||
font-size: 10px;
|
||||
}
|
||||
.cloud {
|
||||
padding: 0 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
:deep(.n-tag__icon) {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.n-icon {
|
||||
font-size: 12px;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
}
|
||||
.mv {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
.artists {
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
.ar {
|
||||
display: inline-flex;
|
||||
transition: opacity 0.3s;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
&::after {
|
||||
content: "/";
|
||||
margin: 0 4px;
|
||||
}
|
||||
&:last-child {
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
.alia {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
cursor: pointer;
|
||||
.sort {
|
||||
margin-left: 6px;
|
||||
&::after {
|
||||
@@ -612,33 +328,35 @@ onActivated(() => {
|
||||
content: "( ";
|
||||
}
|
||||
}
|
||||
&.has-sort {
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
top: 0;
|
||||
left: -8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--primary), 0.08);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
&:hover {
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.album {
|
||||
flex: 1;
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
padding-right: 20px;
|
||||
&:hover {
|
||||
.album-text {
|
||||
color: var(--primary-hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
.n-icon {
|
||||
transition: transform 0.3s;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
.meta {
|
||||
width: 50px;
|
||||
@@ -651,74 +369,45 @@ onActivated(() => {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
&.play {
|
||||
border-color: rgba(var(--primary), 0.58);
|
||||
background-color: rgba(var(--primary), 0.28);
|
||||
}
|
||||
&.header {
|
||||
border: none;
|
||||
}
|
||||
// 滚动条
|
||||
.virt-list__client {
|
||||
transition:
|
||||
height 0.3s,
|
||||
width 0.3s,
|
||||
opacity 0.3s;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
background-color: transparent;
|
||||
.n-text {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.title {
|
||||
position: relative;
|
||||
padding: 0 20px 0 0;
|
||||
&.has-sort {
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
top: 0;
|
||||
left: -8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--primary), 0.08);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
&:hover {
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(var(--primary), 0.28);
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
&.hidden-scrollbar {
|
||||
.list-header {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.song-card {
|
||||
padding-right: 0;
|
||||
}
|
||||
.virt-list__client {
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
:deep(.vue-recycle-scroller__item-view) {
|
||||
&.hover {
|
||||
.song-item {
|
||||
border-color: rgba(var(--primary), 0.58);
|
||||
.num {
|
||||
.n-text,
|
||||
.n-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
.play {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
&.play {
|
||||
.num {
|
||||
.play {
|
||||
display: none;
|
||||
}
|
||||
.status {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.list-after {
|
||||
// 加载更多
|
||||
.load-more {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
margin: 20px 0 40px;
|
||||
.n-spin-body {
|
||||
--n-size: 20px;
|
||||
}
|
||||
@@ -728,14 +417,21 @@ onActivated(() => {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
&.no-padding {
|
||||
margin: 0 -24px;
|
||||
.song-item {
|
||||
margin: 0px 21px 0px 26px;
|
||||
// 加载
|
||||
&.loading {
|
||||
margin-top: 20px;
|
||||
:deep(.n-skeleton) {
|
||||
height: 72px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
// 空列表
|
||||
&.empty {
|
||||
margin-top: 60px;
|
||||
}
|
||||
}
|
||||
.list-button {
|
||||
.list-menu {
|
||||
position: fixed;
|
||||
right: 40px;
|
||||
bottom: 120px;
|
||||
@@ -744,15 +440,4 @@ onActivated(() => {
|
||||
border: 1px solid rgba(var(--primary), 0.28);
|
||||
}
|
||||
}
|
||||
.loading {
|
||||
margin-top: 20px !important;
|
||||
:deep(.n-skeleton) {
|
||||
height: 72px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
.n-empty {
|
||||
margin-top: 60px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,15 +20,16 @@ import type { SongType } from "@/types/main";
|
||||
import { NAlert, type DropdownOption } from "naive-ui";
|
||||
import { useStatusStore, useLocalStore, useDataStore } from "@/stores";
|
||||
import { renderIcon, copyData } from "@/utils/helper";
|
||||
import { deleteCloudSong } from "@/api/cloud";
|
||||
import { deleteCloudSong, importCloudSong } from "@/api/cloud";
|
||||
import {
|
||||
openCloudMatch,
|
||||
openDownloadSong,
|
||||
openPlaylistAdd,
|
||||
openSongInfoEditor,
|
||||
} from "@/utils/modal";
|
||||
import { deleteSongs, isLogin } from "@/utils/auth";
|
||||
import { songUrl } from "@/api/song";
|
||||
import player from "@/utils/player";
|
||||
import { deleteSongs } from "@/utils/auth";
|
||||
|
||||
const emit = defineEmits<{ removeSong: [index: number[]] }>();
|
||||
|
||||
@@ -63,6 +64,7 @@ const openDropdown = (
|
||||
const isHasMv = !!song?.mv && song.mv !== 0;
|
||||
const isCloud = router.currentRoute.value.name === "cloud";
|
||||
const isLocal = !!song?.path;
|
||||
const isLoginNormal = isLogin() === 1;
|
||||
// 是否当前播放
|
||||
const isCurrent = statusStore.playIndex === index;
|
||||
// 是否为用户歌单
|
||||
@@ -165,10 +167,19 @@ const openDropdown = (
|
||||
key: "line-two",
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
key: "cloud-import",
|
||||
label: "导入至云盘",
|
||||
show: !isCloud && isLoginNormal && type === "song" && !isLocal,
|
||||
props: {
|
||||
onClick: () => importSongToCloud(song),
|
||||
},
|
||||
icon: renderIcon("Cloud"),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "从歌单中删除",
|
||||
show: isUserPlaylist && !isCloud,
|
||||
show: isUserPlaylist && isLoginNormal && !isCloud,
|
||||
props: {
|
||||
onClick: () => deleteSongs(playListId!, [song.id], () => emit("removeSong", [song.id])),
|
||||
},
|
||||
@@ -290,6 +301,27 @@ const deleteCloudSongData = (song: SongType, index: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 导入至云盘
|
||||
const importSongToCloud = async (song: SongType) => {
|
||||
if (!song?.id) return;
|
||||
// 获取歌曲下载信息
|
||||
const songData = await songUrl(song.id);
|
||||
const songDetail = songData?.data?.[0];
|
||||
// 开始尝试导入
|
||||
const { id, type, size, br, md5 } = songDetail;
|
||||
const result = await importCloudSong(song?.name, type, size, Math.floor(br / 1000), md5, id);
|
||||
if (result.code === 200) {
|
||||
const failed = result?.data?.failed?.[0];
|
||||
if (failed?.code !== -200) {
|
||||
window.$message.success("导入成功");
|
||||
} else {
|
||||
window.$message.error(failed?.msg || "导入失败,请重试");
|
||||
}
|
||||
} else {
|
||||
window.$message.error("导入失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ openDropdown });
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="create-playlist">
|
||||
<n-tabs v-model:value="playlistType" type="segment" animated>
|
||||
<n-tab-pane name="online" tab="在线歌单">
|
||||
<n-tab-pane :disabled="isLogin() !== 1" name="online" tab="在线歌单">
|
||||
<n-form ref="onlineFormRef" :model="onlineFormData" :rules="onlineFormRules">
|
||||
<n-form-item label="歌单名称" path="name">
|
||||
<n-input v-model:value="onlineFormData.name" placeholder="请输入歌单名称" />
|
||||
@@ -28,7 +28,7 @@ import { useDataStore } from "@/stores";
|
||||
import { textRule } from "@/utils/rules";
|
||||
import { debounce } from "lodash-es";
|
||||
import { createPlaylist } from "@/api/playlist";
|
||||
import { updateUserLikePlaylist } from "@/utils/auth";
|
||||
import { isLogin, updateUserLikePlaylist } from "@/utils/auth";
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
@@ -42,7 +42,7 @@ interface OnlineFormType {
|
||||
const dataStore = useDataStore();
|
||||
|
||||
// 歌单类别
|
||||
const playlistType = ref<"online" | "local">("online");
|
||||
const playlistType = ref<"online" | "local">(isLogin() === 1 ? "online" : "local");
|
||||
|
||||
// 在线歌单数据
|
||||
const onlineFormRef = ref<FormInst | null>(null);
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</n-flex>
|
||||
</n-radio-group>
|
||||
</n-collapse-item>
|
||||
<n-collapse-item v-if="isElectron" title="下载路径" name="path">
|
||||
<n-collapse-item v-if="isElectron" title="本次下载路径" name="path">
|
||||
<n-input-group>
|
||||
<n-input :value="downloadPath || '未配置下载目录'" disabled>
|
||||
<template #prefix>
|
||||
@@ -137,7 +137,7 @@ const changeDownloadPath = async () => {
|
||||
const download = async () => {
|
||||
if (!songData.value) return;
|
||||
loading.value = true;
|
||||
downloadPath.value = settingStore.downloadPath;
|
||||
if (settingStore.downloadPath) downloadPath.value = settingStore.downloadPath;
|
||||
try {
|
||||
// 获取下载链接
|
||||
const result = await songDownloadUrl(props.id, songLevelChoosed.value);
|
||||
@@ -184,7 +184,7 @@ const electronDownload = async (url: string, songName: string, fileType: string)
|
||||
}
|
||||
// 下载歌曲
|
||||
const config = {
|
||||
fileName: songName,
|
||||
fileName: songName.replace(/[/:*?"<>|]/g, "&"),
|
||||
fileType,
|
||||
path: downloadPath.value,
|
||||
downloadMeta,
|
||||
|
||||
20
src/components/Modal/ExcludeKeywords.vue
Normal file
20
src/components/Modal/ExcludeKeywords.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="exclude">
|
||||
<n-alert :show-icon="false">请勿添加过多,以免影响歌词的正常显示</n-alert>
|
||||
<n-dynamic-tags v-model:value="settingStore.excludeKeywords" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from "@/stores";
|
||||
|
||||
const settingStore = useSettingStore();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.exclude {
|
||||
.n-alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,12 +4,22 @@
|
||||
<!-- 登录方式 -->
|
||||
<n-tabs class="login-tabs" default-value="login-qr" type="segment" animated>
|
||||
<n-tab-pane name="login-qr" tab="扫码登录">
|
||||
<loginQRCode @saveLogin="saveLogin" />
|
||||
<LoginQRCode :pause="qrPause" @saveLogin="saveLogin" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="login-phone" tab="验证码登录">
|
||||
<loginPhone @saveLogin="saveLogin" />
|
||||
<LoginPhone @saveLogin="saveLogin" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<!-- 其他方式 -->
|
||||
<n-flex align="center" class="other">
|
||||
<n-button :focusable="false" size="small" quaternary round @click="specialLogin('uid')">
|
||||
UID 登录
|
||||
</n-button>
|
||||
<n-divider vertical />
|
||||
<n-button :focusable="false" size="small" quaternary round @click="specialLogin('cookie')">
|
||||
Cookie 登录
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<!-- 关闭登录 -->
|
||||
<n-button :focusable="false" class="close" strong secondary round @click="emit('close')">
|
||||
<template #icon>
|
||||
@@ -22,8 +32,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { setCookies } from "@/utils/cookie";
|
||||
import { updateUserData } from "@/utils/auth";
|
||||
import { updateSpecialUserData, updateUserData } from "@/utils/auth";
|
||||
import { useDataStore } from "@/stores";
|
||||
import { LoginType } from "@/types/main";
|
||||
import LoginUID from "./LoginUID.vue";
|
||||
import LoginCookie from "./LoginCookie.vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
@@ -31,30 +44,59 @@ const emit = defineEmits<{
|
||||
|
||||
const dataStore = useDataStore();
|
||||
|
||||
// 暂停二维码检查
|
||||
const qrPause = ref(false);
|
||||
|
||||
// 保存登录信息
|
||||
const saveLogin = async (loginData: any) => {
|
||||
const saveLogin = async (loginData: any, type: LoginType = "qr") => {
|
||||
console.log("loginData:", loginData);
|
||||
if (!loginData) return;
|
||||
if (loginData.code === 200) {
|
||||
// 更改状态
|
||||
emit("close");
|
||||
dataStore.userLoginStatus = true;
|
||||
dataStore.loginType = type;
|
||||
window.$message.success("登录成功");
|
||||
// 保存 cookie
|
||||
setCookies(loginData.cookie);
|
||||
if (type !== "uid") setCookies(loginData.cookie);
|
||||
// 保存登录时间
|
||||
localStorage.setItem("lastLoginTime", Date.now().toString());
|
||||
// 获取用户信息
|
||||
await updateUserData();
|
||||
if (type !== "uid") {
|
||||
await updateUserData();
|
||||
} else {
|
||||
await updateSpecialUserData(loginData?.profile);
|
||||
}
|
||||
} else {
|
||||
window.$message.error(loginData.msg ?? loginData.message ?? "账号或密码错误,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
// 特殊登录
|
||||
const specialLogin = (type: "uid" | "cookie" = "uid") => {
|
||||
qrPause.value = true;
|
||||
const loginModal = window.$modal.create({
|
||||
title: type === "uid" ? "UID 登录" : "Cookie 登录",
|
||||
preset: "card",
|
||||
transformOrigin: "center",
|
||||
style: { width: "400px" },
|
||||
content: () => {
|
||||
return h(type === "uid" ? LoginUID : LoginCookie, {
|
||||
onClose: () => loginModal.destroy(),
|
||||
onSaveLogin: saveLogin,
|
||||
});
|
||||
},
|
||||
onClose: () => {
|
||||
qrPause.value = false;
|
||||
loginModal.destroy();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (dataStore.userLoginStatus) {
|
||||
emit("close");
|
||||
window.$message.warning("已登录,请勿再次操作");
|
||||
emit("close");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -71,8 +113,13 @@ onBeforeMount(() => {
|
||||
height: 60px;
|
||||
margin: 20px auto 30px auto;
|
||||
}
|
||||
.other {
|
||||
margin: 20px 0;
|
||||
.n-button {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
.close {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
119
src/components/Modal/Login/LoginCookie.vue
Normal file
119
src/components/Modal/Login/LoginCookie.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="login-cookie">
|
||||
<n-alert :bordered="false" title="如何获取 Cookie">
|
||||
<template #icon>
|
||||
<SvgIcon name="Help" />
|
||||
</template>
|
||||
可在官方的
|
||||
<n-a href="https://music.163.com/" target="_blank">网页端</n-a>
|
||||
或点击下方的自动获取,只需要 Cookie 中的 <code>MUSIC_U</code> 字段即可,例如:
|
||||
<code>MUSIC_U=00C7...;</code><br />请注意:必须以 <code>;</code> 结束
|
||||
</n-alert>
|
||||
<n-input
|
||||
v-model:value="cookie"
|
||||
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||
type="textarea"
|
||||
placeholder="请输入 Cookie"
|
||||
/>
|
||||
<n-flex class="menu">
|
||||
<n-button v-if="isElectron" type="primary" @click="openWeb">自动获取</n-button>
|
||||
<n-button type="primary" @click="login">登录</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LoginType } from "@/types/main";
|
||||
import { isElectron } from "@/utils/helper";
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
saveLogin: [any, LoginType];
|
||||
}>();
|
||||
|
||||
const cookie = ref<string>();
|
||||
|
||||
// 开启窗口
|
||||
const openWeb = () => {
|
||||
window.$dialog.info({
|
||||
title: "使用前告知",
|
||||
content:
|
||||
"请知悉,该功能仍旧无法确保账号的安全性!请自行决定是否使用!如遇打开窗口后页面出现白屏或者无法点击等情况,请关闭后重试",
|
||||
positiveText: "我已了解",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => window.electron.ipcRenderer.send("open-login-web"),
|
||||
});
|
||||
};
|
||||
|
||||
// Cookie 登录
|
||||
const login = async () => {
|
||||
if (!cookie.value) {
|
||||
window.$message.warning("请输入 Cookie");
|
||||
return;
|
||||
}
|
||||
cookie.value = cookie.value.trim();
|
||||
console.log(cookie.value.endsWith(";"));
|
||||
|
||||
// 是否为有效 Cookie
|
||||
if (!cookie.value.includes("MUSIC_U") || !cookie.value.endsWith(";")) {
|
||||
window.$message.warning("请输入有效的 Cookie");
|
||||
return;
|
||||
}
|
||||
// 写入 Cookie
|
||||
try {
|
||||
window.$message.success("登录成功");
|
||||
// 保存登录信息
|
||||
emit(
|
||||
"saveLogin",
|
||||
{
|
||||
code: 200,
|
||||
cookie: cookie.value,
|
||||
},
|
||||
"cookie",
|
||||
);
|
||||
emit("close");
|
||||
} catch (error) {
|
||||
window.$message.error("登录失败,请重试");
|
||||
console.error("Cookie 登录出错:", error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.on("send-cookies", (_, value) => {
|
||||
if (!value) return;
|
||||
cookie.value = value;
|
||||
login();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-cookie {
|
||||
.n-input,
|
||||
.n-button {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
code {
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--n-border-color);
|
||||
padding: 4px 6px;
|
||||
border-radius: 8px;
|
||||
margin: 4px 0;
|
||||
font-family: auto;
|
||||
}
|
||||
.menu {
|
||||
margin-top: 20px;
|
||||
.n-button {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -47,9 +47,10 @@ import { countryList, sentCaptcha, verifyCaptcha, loginPhone } from "@/api/login
|
||||
import { numberRule, phoneRule } from "@/utils/rules";
|
||||
import { getCacheData } from "@/utils/cache";
|
||||
import { debounce } from "lodash-es";
|
||||
import { LoginType } from "@/types/main";
|
||||
|
||||
const emit = defineEmits<{
|
||||
saveLogin: [any];
|
||||
saveLogin: [any, LoginType];
|
||||
}>();
|
||||
|
||||
// 表单类型
|
||||
@@ -66,11 +67,18 @@ const phoneFormData = ref<PhoneFormType>({
|
||||
phone: null,
|
||||
captcha: null,
|
||||
});
|
||||
const phoneFormRules: FormRules = {
|
||||
const phoneFormRules = computed<FormRules>(() => ({
|
||||
country: { ...numberRule, message: "请选择国家" },
|
||||
phone: { ...phoneRule, key: "phone" },
|
||||
phone:
|
||||
phoneFormData.value.country === 86
|
||||
? { ...phoneRule, key: "phone" }
|
||||
: {
|
||||
...numberRule,
|
||||
key: "phone",
|
||||
message: "请输入手机号",
|
||||
},
|
||||
captcha: { ...numberRule, message: "请输入正确的验证码" },
|
||||
};
|
||||
}));
|
||||
|
||||
// 验证码数据
|
||||
const captchaTime = ref<number>(60);
|
||||
@@ -182,7 +190,7 @@ const login = debounce(async (e: MouseEvent) => {
|
||||
// 去除 HTTPOnly
|
||||
loginResult.cookie = loginResult.cookie.replaceAll(" HTTPOnly", "");
|
||||
// 储存登录信息
|
||||
emit("saveLogin", loginResult);
|
||||
emit("saveLogin", loginResult, "phone");
|
||||
} else {
|
||||
window.$message.error("登录出错,请重试");
|
||||
}
|
||||
@@ -39,10 +39,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { qrKey, checkQr } from "@/api/login";
|
||||
import { LoginType } from "@/types/main";
|
||||
import { coverLoaded } from "@/utils/helper";
|
||||
|
||||
const props = defineProps<{
|
||||
pause?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
saveLogin: [any];
|
||||
saveLogin: [any, LoginType];
|
||||
}>();
|
||||
|
||||
// 状态提示
|
||||
@@ -89,7 +94,7 @@ const getQrData = async () => {
|
||||
|
||||
// 检查二维码状态
|
||||
const checkQrStatus = async () => {
|
||||
if (!qrUnikey.value) return;
|
||||
if (!qrUnikey.value || props.pause) return;
|
||||
// 检查状态
|
||||
const { code, cookie, nickname, avatarUrl } = await checkQr(qrUnikey.value);
|
||||
switch (code) {
|
||||
@@ -115,11 +120,12 @@ const checkQrStatus = async () => {
|
||||
// 是否含有 MUSIC_U
|
||||
if (cookie && cookie.includes("MUSIC_U")) {
|
||||
// 储存登录信息
|
||||
emit("saveLogin", { code: 200, cookie });
|
||||
emit("saveLogin", { code: 200, cookie }, "qr");
|
||||
} else {
|
||||
window.$message.error("登录出错,请重试");
|
||||
getQrData();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -142,6 +148,8 @@ onBeforeUnmount(pauseCheck);
|
||||
.qr-img {
|
||||
display: flex;
|
||||
margin: 20px 0;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
.qr {
|
||||
@@ -149,6 +157,8 @@ onBeforeUnmount(pauseCheck);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.n-qr-code {
|
||||
padding: 0;
|
||||
height: 180px;
|
||||
61
src/components/Modal/Login/LoginUID.vue
Normal file
61
src/components/Modal/Login/LoginUID.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="login-uid">
|
||||
<n-alert :bordered="false" title="如何获取 UID">
|
||||
<template #icon>
|
||||
<SvgIcon name="Help" />
|
||||
</template>
|
||||
可前往
|
||||
<n-a href="https://music.163.com/" target="_blank">网易云音乐</n-a>
|
||||
官网登录并前往个人中心,即可从地址栏获取到 UID,也可在客户端分享链接中获取 UID。
|
||||
</n-alert>
|
||||
<n-input-number v-model:value="uid" :show-button="false" placeholder="请输入 UID" />
|
||||
<n-button :loading="!!loadingMsg" type="primary" @click="login">登录</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MessageReactive } from "naive-ui";
|
||||
import type { LoginType } from "@/types/main";
|
||||
import { userDetail } from "@/api/user";
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
saveLogin: [any, LoginType];
|
||||
}>();
|
||||
|
||||
const uid = ref<number>();
|
||||
const loadingMsg = ref<MessageReactive | null>(null);
|
||||
|
||||
// UID 登录
|
||||
const login = async () => {
|
||||
if (!uid.value) {
|
||||
window.$message.warning("请输入 UID");
|
||||
return;
|
||||
}
|
||||
// 检查用户
|
||||
loadingMsg.value = window.$message.loading("正在尝试登录", { duration: 0 });
|
||||
try {
|
||||
const result = await userDetail(uid.value);
|
||||
window.$message.success("登录成功");
|
||||
// 保存登录信息
|
||||
emit("saveLogin", result, "uid");
|
||||
emit("close");
|
||||
} catch (error) {
|
||||
window.$message.error("登录失败,请重试");
|
||||
console.error("UID 登录出错:", error);
|
||||
} finally {
|
||||
loadingMsg.value?.destroy();
|
||||
loadingMsg.value = null;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-uid {
|
||||
.n-input-number,
|
||||
.n-button {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -57,7 +57,7 @@ import { useDataStore } from "@/stores";
|
||||
import { coverLoaded } from "@/utils/helper";
|
||||
import { playlistTracks } from "@/api/playlist";
|
||||
import { debounce } from "lodash-es";
|
||||
import { updateUserLikePlaylist, updateUserLikeSongs } from "@/utils/auth";
|
||||
import { isLogin, updateUserLikePlaylist, updateUserLikeSongs } from "@/utils/auth";
|
||||
import { openCreatePlaylist } from "@/utils/modal";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -86,6 +86,10 @@ const onlinePlaylists = computed(() => {
|
||||
// 添加到歌单
|
||||
const addPlaylist = debounce(
|
||||
async (id: number, index: number) => {
|
||||
if (isLogin() === 2) {
|
||||
window.$message.warning("该登录模式暂不支持该操作");
|
||||
return;
|
||||
}
|
||||
loadingMsg.value = window.$message.loading("正在添加歌曲至歌单", { duration: 0 });
|
||||
const ids = props.data.map((item) => item.id).filter((item) => item !== 0);
|
||||
const result = await playlistTracks(id, ids);
|
||||
|
||||
@@ -200,8 +200,8 @@ const getSongInfo = async () => {
|
||||
name: common.title || "",
|
||||
artist: common.artist || "",
|
||||
album: common.album || "",
|
||||
alia: common.comment?.[0] || "",
|
||||
lyric: common.lyrics?.[0] || "",
|
||||
alia: (common.comment?.[0] as string) || "",
|
||||
lyric: (common.lyrics?.[0] as unknown as string) || "",
|
||||
type: format.codec,
|
||||
duration: format.duration ? Number(format.duration.toFixed(2)) : 0,
|
||||
size: fileSize,
|
||||
@@ -212,7 +212,7 @@ const getSongInfo = async () => {
|
||||
// 获取封面
|
||||
const coverBuff = common.picture?.[0]?.data || "";
|
||||
const coverType = common.picture?.[0]?.format || "";
|
||||
if (coverBuff) coverData.value = blob.createBlobURL(coverBuff, coverType, path);
|
||||
if (coverBuff) coverData.value = blob.createBlobURL(coverBuff as Buffer, coverType, path);
|
||||
};
|
||||
|
||||
// 在线匹配
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
{{ data?.version || "v0.0.0" }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<div v-if="data?.releaseNotes" class="markdown-body" v-html="data.releaseNotes" />
|
||||
<div v-else class="markdown-body">暂无更新日志</div>
|
||||
<n-scrollbar style="max-height: 500px">
|
||||
<div v-if="data?.releaseNotes" class="markdown-body" v-html="data.releaseNotes" />
|
||||
<div v-else class="markdown-body">暂无更新日志</div>
|
||||
</n-scrollbar>
|
||||
<n-flex class="menu" justify="end">
|
||||
<n-button strong secondary @click="emit('close')"> 取消 </n-button>
|
||||
<n-button type="warning" strong secondary @click="goDownload"> 前往下载 </n-button>
|
||||
@@ -39,7 +41,7 @@ const startDownload = async () => {
|
||||
window.electron.ipcRenderer.send("start-download-update");
|
||||
// 监听状态
|
||||
window.electron.ipcRenderer.on("download-progress", (_, progress) => {
|
||||
downloadProgress.value = Number(progress);
|
||||
downloadProgress.value = Number((progress?.percent || 0).toFixed(2));
|
||||
});
|
||||
// 更新错误
|
||||
window.electron.ipcRenderer.on("update-error", (_, error) => {
|
||||
@@ -77,5 +79,8 @@ const goDownload = () => {
|
||||
.menu {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.markdown-body {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<n-scrollbar class="scrollbar">
|
||||
<n-flex class="date" justify="center">
|
||||
<n-tag round>生效日期:2024 年 7 月 16 日</n-tag>
|
||||
<n-tag type="warning" round>更新日期:2024 年 7 月 16 日</n-tag>
|
||||
<n-tag type="warning" round>更新日期:2024 年 9 月 28 日</n-tag>
|
||||
</n-flex>
|
||||
<n-p>
|
||||
欢迎使用 SPlayer(以下简称“本软件”)。本软件是一个本地音乐播放软件,可能会调用第三方 API
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
v-show="statusStore.showFullPlayer"
|
||||
:style="{
|
||||
'--main-color': mainColor,
|
||||
'--main-color': statusStore.mainColor,
|
||||
cursor: statusStore.playerMetaShow ? 'auto' : 'none',
|
||||
}"
|
||||
class="full-player"
|
||||
@@ -29,6 +29,17 @@
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- 独立歌词 -->
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="isShowComment && !statusStore.pureLyricMode"
|
||||
:key="instantLyrics.content"
|
||||
class="lrc-instant"
|
||||
>
|
||||
<span class="lrc">{{ instantLyrics.content }}</span>
|
||||
<span v-if="instantLyrics.tran" class="lrc-tran">{{ instantLyrics.tran }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- 菜单 -->
|
||||
<PlayerMenu @mouseenter.stop="stopHide" @mouseleave.stop="playerMove" />
|
||||
<!-- 主内容 -->
|
||||
@@ -55,16 +66,7 @@
|
||||
<!-- 封面 -->
|
||||
<PlayerCover />
|
||||
<!-- 数据 -->
|
||||
<PlayerData
|
||||
v-if="settingStore.playerType === 'cover' || !musicStore.isHasLrc || isShowComment"
|
||||
:center="
|
||||
statusStore.pureLyricMode ||
|
||||
musicStore.playSong.type === 'radio' ||
|
||||
!musicStore.isHasLrc ||
|
||||
isShowComment
|
||||
"
|
||||
:theme="mainColor"
|
||||
/>
|
||||
<PlayerData :center="playerDataCenter" :theme="statusStore.mainColor" />
|
||||
</div>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<!-- 评论 -->
|
||||
@@ -72,14 +74,14 @@
|
||||
<!-- 歌词 -->
|
||||
<div v-else-if="musicStore.isHasLrc" class="content-right">
|
||||
<!-- 数据 -->
|
||||
<PlayerData
|
||||
<!-- <PlayerData
|
||||
v-if="
|
||||
(statusStore.pureLyricMode && musicStore.isHasLrc) ||
|
||||
(settingStore.playerType === 'record' && musicStore.isHasLrc)
|
||||
"
|
||||
:center="statusStore.pureLyricMode"
|
||||
:theme="mainColor"
|
||||
/>
|
||||
/> -->
|
||||
<!-- 歌词 -->
|
||||
<MainAMLyric v-if="settingStore.useAMLyrics" />
|
||||
<MainLyric v-else />
|
||||
@@ -92,7 +94,7 @@
|
||||
<!-- 音乐频谱 -->
|
||||
<PlayerSpectrum
|
||||
v-if="settingStore.showSpectrums"
|
||||
:color="mainColor ? `rgb(${mainColor})` : 'rgb(239 239 239)'"
|
||||
:color="statusStore.mainColor ? `rgb(${statusStore.mainColor})` : 'rgb(239 239 239)'"
|
||||
:show="!statusStore.playerMetaShow"
|
||||
:height="60"
|
||||
/>
|
||||
@@ -109,6 +111,11 @@ const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
// 是否显示评论
|
||||
const isShowComment = computed<boolean>(
|
||||
() => !musicStore.playSong.path && statusStore.showPlayerComment,
|
||||
);
|
||||
|
||||
// 主内容 key
|
||||
const playerContentKey = computed(() => {
|
||||
return `
|
||||
@@ -118,14 +125,23 @@ const playerContentKey = computed(() => {
|
||||
${isShowComment.value}`;
|
||||
});
|
||||
|
||||
// 是否显示评论
|
||||
const isShowComment = computed(() => !musicStore.playSong.path && statusStore.showPlayerComment);
|
||||
// 数据是否居中
|
||||
const playerDataCenter = computed<boolean>(
|
||||
() =>
|
||||
!musicStore.isHasLrc ||
|
||||
statusStore.pureLyricMode ||
|
||||
settingStore.playerType === "record" ||
|
||||
musicStore.playSong.type === "radio" ||
|
||||
isShowComment.value,
|
||||
);
|
||||
|
||||
// 播放器主色
|
||||
const mainColor = computed(() => {
|
||||
const mainColor = statusStore.songCoverTheme?.main;
|
||||
if (!mainColor) return "239, 239, 239";
|
||||
return `${mainColor.r}, ${mainColor.g}, ${mainColor.b}`;
|
||||
// 当前实时歌词
|
||||
const instantLyrics = computed(() => {
|
||||
const isYrc = musicStore.songLyric.yrcData?.length && settingStore.showYrc;
|
||||
const content = isYrc
|
||||
? musicStore.songLyric.yrcData[statusStore.lyricIndex]
|
||||
: musicStore.songLyric.lrcData[statusStore.lyricIndex];
|
||||
return { content: content?.content, tran: settingStore.showTran && content?.tran };
|
||||
});
|
||||
|
||||
// 隐藏播放元素
|
||||
@@ -222,6 +238,23 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
.lrc-instant {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
.lrc {
|
||||
font-size: 18px;
|
||||
}
|
||||
.lrc-tran {
|
||||
font-size: 14px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
.player-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -50,9 +50,8 @@ const { pause: pauseSeek, resume: resumeSeek } = useRafFn(() => {
|
||||
|
||||
// 歌词主色
|
||||
const mainColor = computed(() => {
|
||||
const mainColor = statusStore.songCoverTheme?.main;
|
||||
if (!mainColor) return "rgb(239, 239, 239)";
|
||||
return `rgb(${mainColor.r}, ${mainColor.g}, ${mainColor.b})`;
|
||||
if (!statusStore.mainColor) return "rgb(239, 239, 239)";
|
||||
return `rgb(${statusStore.mainColor})`;
|
||||
});
|
||||
|
||||
// 当前歌词
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
:key="textIndex"
|
||||
:class="{
|
||||
'content-text': true,
|
||||
'content-long': text.duration >= 1.5,
|
||||
'content-long': text.duration >= 1.5 && playSeek <= text.endTime,
|
||||
'end-with-space': text.endsWithSpace,
|
||||
}"
|
||||
>
|
||||
@@ -67,9 +67,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- 翻译 -->
|
||||
<span v-if="item.tran" class="tran">{{ item.tran }}</span>
|
||||
<span v-if="item.tran && settingStore.showTran" class="tran">{{ item.tran }}</span>
|
||||
<!-- 音译 -->
|
||||
<span v-if="item.roma" class="roma">{{ item.roma }}</span>
|
||||
<span v-if="item.roma && settingStore.showRoma" class="roma">{{ item.roma }}</span>
|
||||
<!-- 倒计时 -->
|
||||
<div
|
||||
v-if="
|
||||
@@ -115,9 +115,9 @@
|
||||
<!-- 歌词 -->
|
||||
<span class="content">{{ item.content }}</span>
|
||||
<!-- 翻译 -->
|
||||
<span v-if="item.tran" class="tran">{{ item.tran }}</span>
|
||||
<span v-if="item.tran && settingStore.showTran" class="tran">{{ item.tran }}</span>
|
||||
<!-- 音译 -->
|
||||
<span v-if="item.roma" class="roma">{{ item.roma }}</span>
|
||||
<span v-if="item.roma && settingStore.showRoma" class="roma">{{ item.roma }}</span>
|
||||
</div>
|
||||
<div class="placeholder" />
|
||||
</template>
|
||||
@@ -149,8 +149,8 @@
|
||||
import type { LyricContentType } from "@/types/main";
|
||||
import { NScrollbar } from "naive-ui";
|
||||
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
|
||||
import player from "@/utils/player";
|
||||
import { openSetting } from "@/utils/modal";
|
||||
import player from "@/utils/player";
|
||||
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
@@ -324,6 +324,7 @@ onBeforeUnmount(() => {
|
||||
padding: 10px 16px;
|
||||
transform: scale(0.86);
|
||||
transform-origin: left center;
|
||||
will-change: filter, opacity, transform;
|
||||
transition:
|
||||
filter 0.35s,
|
||||
opacity 0.35s,
|
||||
@@ -364,7 +365,11 @@ onBeforeUnmount(() => {
|
||||
);
|
||||
-webkit-mask-size: 220% 100%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
transition: opacity 0.3s !important;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
filter 0.5s,
|
||||
margin 0.3s,
|
||||
padding 0.3s !important;
|
||||
}
|
||||
&.end-with-space {
|
||||
margin-right: 12px;
|
||||
@@ -567,8 +572,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
&.record,
|
||||
&.pure {
|
||||
:deep(.n-scrollbar-content) {
|
||||
padding: 0 80px;
|
||||
}
|
||||
.lyric-content {
|
||||
.placeholder {
|
||||
&:first-child {
|
||||
@@ -584,11 +591,6 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
&.pure {
|
||||
:deep(.n-scrollbar-content) {
|
||||
padding: 0 80px;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.lrc-line {
|
||||
filter: blur(0) !important;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<n-drawer
|
||||
v-model:show="statusStore.playListShow"
|
||||
:class="{ 'full-player': statusStore.showFullPlayer }"
|
||||
:style="{ '--main-color': mainColor }"
|
||||
:style="{ '--main-color': statusStore.mainColor }"
|
||||
:auto-focus="false"
|
||||
id="main-playlist"
|
||||
style="width: 400px"
|
||||
@@ -124,13 +124,6 @@ const statusStore = useStatusStore();
|
||||
|
||||
const playListRef = ref<VirtualListInst | null>(null);
|
||||
|
||||
// 列表主色
|
||||
const mainColor = computed(() => {
|
||||
const mainColor = statusStore.songCoverTheme?.main;
|
||||
if (!mainColor) return "239, 239, 239";
|
||||
return `${mainColor.r}, ${mainColor.g}, ${mainColor.b}`;
|
||||
});
|
||||
|
||||
// 播放列表数据
|
||||
const playListData = computed(() => {
|
||||
return dataStore.playList.map((item, index) => {
|
||||
@@ -290,6 +283,9 @@ const scrollToItem = (index: number, behavior: "smooth" | "auto" = "smooth") =>
|
||||
&.on {
|
||||
border-color: rgb(var(--main-color));
|
||||
}
|
||||
&:hover {
|
||||
border-color: rgb(var(--main-color));
|
||||
}
|
||||
.num {
|
||||
color: rgba(var(--main-color), 0.52);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
:max="100"
|
||||
:tooltip="false"
|
||||
:keyboard="false"
|
||||
:marks="
|
||||
statusStore.chorus && statusStore.progress <= statusStore.chorus
|
||||
? { [statusStore.chorus]: '' }
|
||||
: undefined
|
||||
"
|
||||
class="player-slider"
|
||||
@dragstart="player.pause(false)"
|
||||
@dragend="sliderDragend"
|
||||
@@ -168,17 +173,17 @@
|
||||
@select="(mode) => player.togglePlayMode(mode)"
|
||||
>
|
||||
<div class="menu-icon" @click.stop="player.togglePlayMode(false)">
|
||||
<SvgIcon :name="playModeIcon" />
|
||||
<SvgIcon :name="statusStore.playModeIcon" />
|
||||
</div>
|
||||
</n-dropdown>
|
||||
<!-- 音量调节 -->
|
||||
<n-popover :show-arrow="false" :style="{ padding: 0 }">
|
||||
<template #trigger>
|
||||
<div class="menu-icon" @click.stop="player.toggleMute" @wheel="changeVolume">
|
||||
<SvgIcon :name="playVolumeIcon" />
|
||||
<div class="menu-icon" @click.stop="player.toggleMute" @wheel="player.setVolume">
|
||||
<SvgIcon :name="statusStore.playVolumeIcon" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="volume-change" @wheel="changeVolume">
|
||||
<div class="volume-change" @wheel="player.setVolume">
|
||||
<n-slider
|
||||
v-model:value="statusStore.playVolume"
|
||||
:tooltip="false"
|
||||
@@ -188,7 +193,7 @@
|
||||
vertical
|
||||
@update:value="(val) => player.setVolume(val)"
|
||||
/>
|
||||
<n-text class="slider-num">{{ playVolumePercentage }}%</n-text>
|
||||
<n-text class="slider-num">{{ statusStore.playVolumePercent }}%</n-text>
|
||||
</div>
|
||||
</n-popover>
|
||||
<!-- 播放列表 -->
|
||||
@@ -214,7 +219,7 @@
|
||||
import type { DropdownOption } from "naive-ui";
|
||||
import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores";
|
||||
import { secondsToTime, calculateCurrentTime } from "@/utils/time";
|
||||
import { renderIcon, isElectron } from "@/utils/helper";
|
||||
import { renderIcon, isElectron, coverLoaded } from "@/utils/helper";
|
||||
import { toLikeSong } from "@/utils/auth";
|
||||
import { openDownloadSong, openJumpArtist, openPlaylistAdd } from "@/utils/modal";
|
||||
import player from "@/utils/player";
|
||||
@@ -277,6 +282,20 @@ const songMoreOptions = computed<DropdownOption[]>(() => {
|
||||
props: { onClick: () => openDownloadSong(musicStore.playSong) },
|
||||
icon: renderIcon("Download"),
|
||||
},
|
||||
{
|
||||
key: "comment",
|
||||
label: "查看评论",
|
||||
show: !isLocal,
|
||||
props: {
|
||||
onClick: () => {
|
||||
statusStore.$patch({
|
||||
showFullPlayer: true,
|
||||
showPlayerComment: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
icon: renderIcon("Message"),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -289,43 +308,6 @@ const sliderDragend = () => {
|
||||
player.play();
|
||||
};
|
||||
|
||||
// 封面加载完成
|
||||
const coverLoaded = (e: Event) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && target.nodeType === Node.ELEMENT_NODE) {
|
||||
target.style.opacity = "1";
|
||||
}
|
||||
};
|
||||
|
||||
// 当前音量百分比
|
||||
const playVolumePercentage = computed(() => {
|
||||
return Math.round(statusStore.playVolume * 100);
|
||||
});
|
||||
|
||||
// 当前音量图标
|
||||
const playVolumeIcon = computed(() => {
|
||||
const volume = statusStore.playVolume;
|
||||
return volume === 0
|
||||
? "VolumeOff"
|
||||
: volume < 0.4
|
||||
? "VolumeMute"
|
||||
: volume < 0.7
|
||||
? "VolumeDown"
|
||||
: "VolumeUp";
|
||||
});
|
||||
|
||||
// 当前播放模式图标
|
||||
const playModeIcon = computed(() => {
|
||||
const mode = statusStore.playSongMode;
|
||||
return statusStore.playHeartbeatMode
|
||||
? "HeartBit"
|
||||
: mode === "repeat"
|
||||
? "Repeat"
|
||||
: mode === "repeat-once"
|
||||
? "RepeatSong"
|
||||
: "Shuffle";
|
||||
});
|
||||
|
||||
// 是否展示歌词
|
||||
const isShowLyrics = computed(() => {
|
||||
const isHasLrc = musicStore.isHasLrc;
|
||||
@@ -344,14 +326,10 @@ const instantLyrics = computed(() => {
|
||||
const content = isYrc
|
||||
? musicStore.songLyric.yrcData[statusStore.lyricIndex]
|
||||
: musicStore.songLyric.lrcData[statusStore.lyricIndex];
|
||||
return content?.tran ? `${content?.content}( ${content?.tran} )` : content?.content;
|
||||
return content?.tran && settingStore.showTran
|
||||
? `${content?.content}( ${content?.tran} )`
|
||||
: content?.content;
|
||||
});
|
||||
|
||||
// 音量条鼠标滚动
|
||||
const changeVolume = (e: WheelEvent) => {
|
||||
const deltaY = e.deltaY;
|
||||
player.setVolume(deltaY > 0 ? "down" : "up");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -378,6 +356,7 @@ const changeVolume = (e: WheelEvent) => {
|
||||
height: 16px;
|
||||
top: -8px;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
--n-rail-height: 3px;
|
||||
--n-handle-size: 14px;
|
||||
}
|
||||
@@ -449,6 +428,7 @@ const changeVolume = (e: WheelEvent) => {
|
||||
transition: color 0.3s;
|
||||
}
|
||||
.like {
|
||||
color: var(--primary-hex);
|
||||
margin-left: 8px;
|
||||
transition: transform 0.3s;
|
||||
cursor: pointer;
|
||||
@@ -552,6 +532,8 @@ const changeVolume = (e: WheelEvent) => {
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
.n-text {
|
||||
color: var(--primary-hex);
|
||||
opacity: 0.8;
|
||||
&:nth-of-type(1) {
|
||||
&::after {
|
||||
content: "/";
|
||||
@@ -572,6 +554,7 @@ const changeVolume = (e: WheelEvent) => {
|
||||
cursor: pointer;
|
||||
.n-icon {
|
||||
font-size: 22px;
|
||||
color: var(--primary-hex);
|
||||
}
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
@@ -598,9 +581,6 @@ const changeVolume = (e: WheelEvent) => {
|
||||
width: 64px;
|
||||
height: 200px;
|
||||
padding: 12px 16px;
|
||||
.n-slider {
|
||||
--n-rail-width-vertical: 18px;
|
||||
}
|
||||
.slider-num {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -195,6 +195,7 @@ onMounted(player.initPersonalFM);
|
||||
--n-width: 46px;
|
||||
--n-height: 46px;
|
||||
.n-icon {
|
||||
color: var(--primary-hex);
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
}
|
||||
}
|
||||
@@ -205,6 +206,7 @@ onMounted(player.initPersonalFM);
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
color: var(--primary-hex);
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
transform 0.3s;
|
||||
|
||||
@@ -129,6 +129,9 @@ onMounted(() => {
|
||||
:deep(.n-scrollbar-content) {
|
||||
padding-right: 60px;
|
||||
}
|
||||
:deep(.n-skeleton) {
|
||||
background-color: rgba(var(--main-color), 0.08);
|
||||
}
|
||||
.comment-list {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
>
|
||||
<SvgIcon name="AddList" />
|
||||
</div>
|
||||
<!-- 下载 -->
|
||||
<div class="menu-icon" @click.stop="openDownloadSong(musicStore.playSong)">
|
||||
<SvgIcon name="Download" />
|
||||
</div>
|
||||
</n-flex>
|
||||
<div class="center">
|
||||
<div class="btn">
|
||||
@@ -90,8 +94,28 @@
|
||||
</div>
|
||||
<!-- 播放模式 -->
|
||||
<div class="menu-icon" @click.stop="player.togglePlayMode(false)">
|
||||
<SvgIcon :name="playModeIcon" />
|
||||
<SvgIcon :name="statusStore.playModeIcon" />
|
||||
</div>
|
||||
<!-- 音量调节 -->
|
||||
<n-popover :show-arrow="false" :style="{ '--main-color': statusStore.mainColor }" raw>
|
||||
<template #trigger>
|
||||
<div class="menu-icon" @click.stop="player.toggleMute" @wheel="player.setVolume">
|
||||
<SvgIcon :name="statusStore.playVolumeIcon" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="volume-change" @wheel="player.setVolume">
|
||||
<n-slider
|
||||
v-model:value="statusStore.playVolume"
|
||||
:tooltip="false"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
vertical
|
||||
@update:value="(val) => player.setVolume(val)"
|
||||
/>
|
||||
<n-text class="slider-num">{{ statusStore.playVolumePercent }}%</n-text>
|
||||
</div>
|
||||
</n-popover>
|
||||
<!-- 播放列表 -->
|
||||
<div
|
||||
v-if="!statusStore.personalFmMode"
|
||||
@@ -109,7 +133,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useMusicStore, useStatusStore, useDataStore } from "@/stores";
|
||||
import { secondsToTime, calculateCurrentTime } from "@/utils/time";
|
||||
import { openPlaylistAdd } from "@/utils/modal";
|
||||
import { openDownloadSong, openPlaylistAdd } from "@/utils/modal";
|
||||
import { toLikeSong } from "@/utils/auth";
|
||||
import player from "@/utils/player";
|
||||
|
||||
@@ -117,18 +141,6 @@ const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
// 当前播放模式图标
|
||||
const playModeIcon = computed(() => {
|
||||
const mode = statusStore.playSongMode;
|
||||
return statusStore.playHeartbeatMode
|
||||
? "HeartBit"
|
||||
: mode === "repeat"
|
||||
? "Repeat"
|
||||
: mode === "repeat-once"
|
||||
? "RepeatSong"
|
||||
: "Shuffle";
|
||||
});
|
||||
|
||||
// 进度条拖拽结束
|
||||
const sliderDragend = () => {
|
||||
const seek = calculateCurrentTime(statusStore.progress, statusStore.duration);
|
||||
@@ -255,11 +267,6 @@ const sliderDragend = () => {
|
||||
margin: 6px 8px;
|
||||
--n-handle-size: 12px;
|
||||
--n-rail-height: 4px;
|
||||
--n-rail-color: rgba(var(--main-color), 0.14);
|
||||
--n-rail-color-hover: rgba(var(--main-color), 0.3);
|
||||
--n-fill-color: rgb(var(--main-color));
|
||||
--n-handle-color: rgb(var(--main-color));
|
||||
--n-fill-color-hover: rgb(var(--main-color));
|
||||
}
|
||||
span {
|
||||
opacity: 0.6;
|
||||
@@ -273,4 +280,28 @@ const sliderDragend = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
// volume
|
||||
.volume-change {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 64px;
|
||||
height: 200px;
|
||||
padding: 12px 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
background-color: rgba(var(--main-color), 0.14);
|
||||
.slider-num {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--main-color));
|
||||
}
|
||||
}
|
||||
// slider
|
||||
.n-slider {
|
||||
--n-rail-color: rgba(var(--main-color), 0.14);
|
||||
--n-rail-color-hover: rgba(var(--main-color), 0.3);
|
||||
--n-fill-color: rgb(var(--main-color));
|
||||
--n-handle-color: rgb(var(--main-color));
|
||||
--n-fill-color-hover: rgb(var(--main-color));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,36 +8,86 @@
|
||||
alt="pointer"
|
||||
/>
|
||||
<!-- 专辑图片 -->
|
||||
<n-image
|
||||
<s-image
|
||||
:key="musicStore.getSongCover()"
|
||||
:src="musicStore.getSongCover('l')"
|
||||
class="cover-img"
|
||||
preview-disabled
|
||||
@load="coverLoaded"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="cover-loading">
|
||||
<img src="/images/song.jpg?assest" class="loading-img" alt="loading-img" />
|
||||
</div>
|
||||
</template>
|
||||
</n-image>
|
||||
/>
|
||||
<!-- 动态封面 -->
|
||||
<Transition name="fade" mode="out-in">
|
||||
<video
|
||||
v-if="dynamicCover && settingStore.dynamicCover && settingStore.playerType === 'cover'"
|
||||
ref="videoRef"
|
||||
:src="dynamicCover"
|
||||
:class="['dynamic-cover', { loaded: dynamicCoverLoaded }]"
|
||||
muted
|
||||
autoplay
|
||||
@loadeddata="dynamicCoverLoaded = true"
|
||||
@ended="dynamicCoverEnded"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { songDynamicCover } from "@/api/song";
|
||||
import { useSettingStore, useStatusStore, useMusicStore } from "@/stores";
|
||||
import { isLogin } from "@/utils/auth";
|
||||
import { isEmpty } from "lodash-es";
|
||||
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
// 封面加载完成
|
||||
const coverLoaded = (e: Event) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && target.nodeType === Node.ELEMENT_NODE) {
|
||||
target.style.opacity = "1";
|
||||
// 动态封面
|
||||
const dynamicCover = ref<string>("");
|
||||
const dynamicCoverLoaded = ref<boolean>(false);
|
||||
|
||||
// 视频元素
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
|
||||
// 封面再放送
|
||||
const { start: dynamicCoverStart, stop: dynamicCoverStop } = useTimeoutFn(
|
||||
() => {
|
||||
dynamicCoverLoaded.value = true;
|
||||
videoRef.value?.play();
|
||||
},
|
||||
2000,
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
// 获取动态封面
|
||||
const getDynamicCover = async () => {
|
||||
if (
|
||||
isLogin() !== 1 ||
|
||||
musicStore.playSong.path ||
|
||||
!musicStore.playSong.id ||
|
||||
!settingStore.dynamicCover ||
|
||||
settingStore.playerType !== "cover"
|
||||
)
|
||||
return;
|
||||
dynamicCoverStop();
|
||||
dynamicCoverLoaded.value = false;
|
||||
const result = await songDynamicCover(musicStore.playSong.id);
|
||||
if (!isEmpty(result.data) && result?.data?.videoPlayUrl) {
|
||||
dynamicCover.value = result.data.videoPlayUrl;
|
||||
} else {
|
||||
dynamicCover.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// 封面播放结束
|
||||
const dynamicCoverEnded = () => {
|
||||
dynamicCoverLoaded.value = false;
|
||||
dynamicCoverStart();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [musicStore.playSong.id, settingStore.dynamicCover, settingStore.playerType],
|
||||
() => getDynamicCover(),
|
||||
);
|
||||
|
||||
onMounted(getDynamicCover);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -56,16 +106,26 @@ const coverLoaded = (e: Event) => {
|
||||
.cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 32px;
|
||||
overflow: hidden;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
box-shadow: 0 0 20px 10px rgba(0, 0, 0, 0.1);
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
:deep(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
.dynamic-cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 32px;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.8s ease-in-out;
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&.record {
|
||||
@@ -177,6 +237,8 @@ const coverLoaded = (e: Event) => {
|
||||
}
|
||||
}
|
||||
&.cover {
|
||||
border-radius: 32px;
|
||||
overflow: hidden;
|
||||
transform: scale(0.9);
|
||||
&.playing {
|
||||
transform: scale(1);
|
||||
|
||||
@@ -27,7 +27,12 @@
|
||||
<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">
|
||||
<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>
|
||||
@@ -44,16 +49,23 @@
|
||||
<!-- 专辑 -->
|
||||
<div v-if="musicStore.playSong.type !== 'radio'" class="album">
|
||||
<SvgIcon :depth="3" name="Album" size="20" />
|
||||
<span class="name-text text-hidden">
|
||||
{{
|
||||
typeof musicStore.playSong.album === "string"
|
||||
? musicStore.playSong.album || "未知专辑"
|
||||
: musicStore.playSong.album?.name || "未知专辑"
|
||||
}}
|
||||
<span
|
||||
v-if="isObject(musicStore.playSong.album)"
|
||||
class="name-text text-hidden"
|
||||
@click="jumpPage({ name: 'album', query: { id: musicStore.playSong.album.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">
|
||||
<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>
|
||||
@@ -61,16 +73,32 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationRaw } from "vue-router";
|
||||
import { useMusicStore, useStatusStore, useSettingStore } from "@/stores";
|
||||
import { debounce, isObject } from "lodash-es";
|
||||
|
||||
defineProps<{
|
||||
center?: boolean;
|
||||
theme?: string;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
const jumpPage = debounce(
|
||||
(go: RouteLocationRaw) => {
|
||||
if (!go) return;
|
||||
statusStore.showFullPlayer = false;
|
||||
router.push(go);
|
||||
},
|
||||
300,
|
||||
{
|
||||
leading: true,
|
||||
trailing: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
/>
|
||||
</Transition>
|
||||
<!-- 默认内容 -->
|
||||
<SearchDefault @to-search="toSearch" />
|
||||
<SearchDefault v-if="settingStore.useOnlineService" @to-search="toSearch" />
|
||||
<!-- 搜索结果 -->
|
||||
<SearchSuggest @to-search="toSearch" />
|
||||
<!-- 右键菜单 -->
|
||||
@@ -37,20 +37,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStatusStore, useDataStore } from "@/stores";
|
||||
import SearchInpMenu from "@/components/Menu/SearchInpMenu.vue";
|
||||
import { useStatusStore, useDataStore, useSettingStore } from "@/stores";
|
||||
import { searchDefault } from "@/api/search";
|
||||
import SearchInpMenu from "@/components/Menu/SearchInpMenu.vue";
|
||||
import player from "@/utils/player";
|
||||
import { songDetail } from "@/api/song";
|
||||
import { formatSongsList } from "@/utils/format";
|
||||
|
||||
const router = useRouter();
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
// 右键菜单
|
||||
const searchInpMenuRef = ref<InstanceType<typeof SearchInpMenu> | null>(null);
|
||||
|
||||
// 搜索框数据
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
const searchPlaceholder = ref<string>("搜索音乐 / 视频");
|
||||
const searchPlaceholder = ref<string>(
|
||||
settingStore.useOnlineService ? "搜索音乐 / 视频" : "搜索本地音乐",
|
||||
);
|
||||
const searchRealkeyword = ref<string>("");
|
||||
|
||||
// 搜索框输入限制
|
||||
@@ -90,15 +96,24 @@ const updatePlaceholder = async () => {
|
||||
};
|
||||
|
||||
// 前往搜索
|
||||
const toSearch = (key: any, type: string = "keyword") => {
|
||||
const toSearch = async (key: any, type: string = "keyword") => {
|
||||
// 关闭搜索框
|
||||
statusStore.searchFocus = false;
|
||||
searchInputRef.value?.blur();
|
||||
// 未输入内容且不存在推荐
|
||||
if (!key && searchPlaceholder.value === "搜索音乐 / 视频") return;
|
||||
if (!key && searchPlaceholder.value !== "搜索音乐 / 视频" && searchRealkeyword.value) {
|
||||
key = searchRealkeyword.value?.trim();
|
||||
}
|
||||
// 关闭搜索框
|
||||
statusStore.searchFocus = false;
|
||||
searchInputRef.value?.blur();
|
||||
// 本地搜索
|
||||
if (!settingStore.useOnlineService) {
|
||||
// 跳转本地搜索页面
|
||||
router.push({
|
||||
name: "search",
|
||||
query: { keyword: key },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 更新推荐
|
||||
updatePlaceholder();
|
||||
// 前往搜索
|
||||
@@ -110,8 +125,12 @@ const toSearch = (key: any, type: string = "keyword") => {
|
||||
});
|
||||
setSearchHistory(key);
|
||||
break;
|
||||
case "songs":
|
||||
case "songs": {
|
||||
const result = await songDetail(key?.id);
|
||||
const song = formatSongsList(result.songs)[0];
|
||||
player.addNextSong(song, true);
|
||||
break;
|
||||
}
|
||||
case "playlists":
|
||||
router.push({
|
||||
name: "playlist",
|
||||
@@ -119,6 +138,10 @@ const toSearch = (key: any, type: string = "keyword") => {
|
||||
});
|
||||
break;
|
||||
case "artists":
|
||||
router.push({
|
||||
name: "artist",
|
||||
query: { id: key?.id },
|
||||
});
|
||||
break;
|
||||
case "albums":
|
||||
router.push({
|
||||
@@ -132,9 +155,10 @@ const toSearch = (key: any, type: string = "keyword") => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updatePlaceholder();
|
||||
// 每分钟更新
|
||||
useIntervalFn(updatePlaceholder, 60 * 1000);
|
||||
if (settingStore.useOnlineService) {
|
||||
useIntervalFn(updatePlaceholder, 60 * 1000, { immediate: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
<!-- 搜索建议 -->
|
||||
<Transition name="fade" mode="out-in" @after-leave="calcSearchSuggestHeights">
|
||||
<div
|
||||
v-if="Object.keys(searchSuggestData)?.length && searchSuggestData?.order"
|
||||
v-if="
|
||||
Object.keys(searchSuggestData)?.length &&
|
||||
searchSuggestData?.order &&
|
||||
settingStore.useOnlineService
|
||||
"
|
||||
ref="searchSuggestRef"
|
||||
class="all-suggest"
|
||||
>
|
||||
@@ -60,13 +64,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { searchSuggest } from "@/api/search";
|
||||
import { useStatusStore } from "@/stores";
|
||||
import { useStatusStore, useSettingStore } from "@/stores";
|
||||
|
||||
const emit = defineEmits<{
|
||||
toSearch: [key: number | string, type: string];
|
||||
}>();
|
||||
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
// 搜索建议数据
|
||||
const searchSuggestData = ref<any>({});
|
||||
@@ -125,7 +130,7 @@ const calcSearchSuggestHeights = () => {
|
||||
watchDebounced(
|
||||
() => statusStore.searchInputValue,
|
||||
(val) => {
|
||||
if (!val || val === "") return;
|
||||
if (!val || val === "" || !settingStore.useOnlineService) return;
|
||||
getSearchSuggest(val);
|
||||
},
|
||||
{ debounce: 300 },
|
||||
|
||||
@@ -10,7 +10,15 @@
|
||||
{{ packageJson.version }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-button type="primary" strong secondary @click="checkUpdate"> 检查更新 </n-button>
|
||||
<n-button
|
||||
:loading="statusStore.updateCheck"
|
||||
type="primary"
|
||||
strong
|
||||
secondary
|
||||
@click="checkUpdate"
|
||||
>
|
||||
{{ statusStore.updateCheck ? "检查更新中" : "检查更新" }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
<n-collapse-transition :show="!!updateData">
|
||||
<n-card class="set-item update-data">
|
||||
@@ -24,7 +32,7 @@
|
||||
</n-tag>
|
||||
<n-text :depth="3" class="time">{{ newVersion?.time }}</n-text>
|
||||
</n-flex>
|
||||
<div class="markdown-body" v-html="newVersion?.changelog" />
|
||||
<div class="markdown-body" v-html="newVersion?.changelog" @click="jumpLink" />
|
||||
</n-card>
|
||||
</n-collapse-transition>
|
||||
</div>
|
||||
@@ -45,7 +53,7 @@
|
||||
</n-tag>
|
||||
<n-text :depth="3" class="time">{{ item?.time }}</n-text>
|
||||
</n-flex>
|
||||
<div class="markdown-body" v-html="item?.changelog" />
|
||||
<div class="markdown-body" v-html="item?.changelog" @click="jumpLink" />
|
||||
</n-card>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
@@ -73,8 +81,11 @@
|
||||
import type { UpdateLogType } from "@/types/main";
|
||||
import { getUpdateLog, isElectron, openLink } from "@/utils/helper";
|
||||
import { debounce } from "lodash-es";
|
||||
import { useStatusStore } from "@/stores";
|
||||
import packageJson from "@/../package.json";
|
||||
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
// 社区数据
|
||||
const communityData = [
|
||||
{
|
||||
@@ -102,18 +113,28 @@ const oldVersion = computed<UpdateLogType[]>(() => {
|
||||
});
|
||||
|
||||
// 检查更新
|
||||
const checkUpdate = debounce(() => {
|
||||
if (!isElectron) {
|
||||
window.open(packageJson.github + "/releases", "_blank");
|
||||
const checkUpdate = debounce(
|
||||
() => {
|
||||
if (!isElectron) {
|
||||
window.open(packageJson.github + "/releases", "_blank");
|
||||
return;
|
||||
}
|
||||
statusStore.updateCheck = true;
|
||||
window.electron.ipcRenderer.send("check-update", true);
|
||||
},
|
||||
300,
|
||||
{ leading: true, trailing: false },
|
||||
);
|
||||
|
||||
// 链接跳转
|
||||
const jumpLink = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName !== "A") {
|
||||
return;
|
||||
}
|
||||
window.$notification.info({
|
||||
title: "检查更新",
|
||||
content: "正在检查更新,请稍后...",
|
||||
duration: 3000,
|
||||
});
|
||||
window.electron.ipcRenderer.send("check-update", true);
|
||||
}, 300);
|
||||
e.preventDefault();
|
||||
openLink((target as HTMLAnchorElement).href);
|
||||
};
|
||||
|
||||
// 获取更新日志
|
||||
const getUpdateData = async () => (updateData.value = await getUpdateLog());
|
||||
|
||||
@@ -95,6 +95,13 @@
|
||||
</div>
|
||||
<n-switch class="set" v-model:value="settingStore.menuShowCover" :round="false" />
|
||||
</n-card>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">开启页面缓存</n-text>
|
||||
<n-text class="tip" :depth="3">是否开启部分页面的缓存,这将会增加内存占用</n-text>
|
||||
</div>
|
||||
<n-switch class="set" v-model:value="settingStore.useKeepAlive" :round="false" />
|
||||
</n-card>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">页面切换动画</n-text>
|
||||
@@ -243,12 +250,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from "naive-ui";
|
||||
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
|
||||
import { isElectron } from "@/utils/helper";
|
||||
import { useDataStore, useMusicStore, useSettingStore, useStatusStore } from "@/stores";
|
||||
import { isDev, isElectron } from "@/utils/helper";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import themeColor from "@/assets/data/themeColor.json";
|
||||
import player from "@/utils/player";
|
||||
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
const settingStore = useSettingStore();
|
||||
const statusStore = useStatusStore();
|
||||
@@ -306,26 +314,40 @@ const modeChange = (val: boolean) => {
|
||||
if (val) {
|
||||
window.$dialog.warning({
|
||||
title: "开启在线服务",
|
||||
content: "确定开启软件的在线服务?更改将在重启后生效!",
|
||||
content: "确定开启软件的在线服务?更改将在热重载后生效!",
|
||||
positiveText: "开启",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => {
|
||||
useOnlineService.value = true;
|
||||
settingStore.useOnlineService = true;
|
||||
// 清理播放数据
|
||||
dataStore.$reset();
|
||||
musicStore.$reset();
|
||||
// 清空本地数据
|
||||
localStorage.removeItem("data-store");
|
||||
localStorage.removeItem("music-store");
|
||||
// 热重载
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
window.$dialog.warning({
|
||||
title: "关闭在线服务",
|
||||
content:
|
||||
"确定关闭软件的在线服务?将关闭包括搜索、登录、在线音乐播放等在内的全部在线服务,软件将会变为本地播放器!更改将在软件重启后生效!",
|
||||
"确定关闭软件的在线服务?将关闭包括搜索、登录、在线音乐播放等在内的全部在线服务,并且将会退出登录状态,软件将会变为本地播放器!更改将在重启后生效!",
|
||||
positiveText: "关闭",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: () => {
|
||||
useOnlineService.value = false;
|
||||
settingStore.useOnlineService = false;
|
||||
// 清理播放数据
|
||||
dataStore.$reset();
|
||||
musicStore.$reset();
|
||||
// 清空本地数据
|
||||
localStorage.removeItem("data-store");
|
||||
localStorage.removeItem("music-store");
|
||||
// 重启
|
||||
window.electron.ipcRenderer.send("win-reload");
|
||||
if (!isDev) window.electron.ipcRenderer.send("win-reload");
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
useOnlineService.value = true;
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
</div>
|
||||
<n-switch class="set" v-model:value="settingStore.showLocalCover" :round="false" />
|
||||
</n-card>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">显示本地默认歌曲目录</n-text>
|
||||
</div>
|
||||
<n-switch class="set" v-model:value="settingStore.showDefaultLocalPath" :round="false" />
|
||||
</n-card>
|
||||
<n-card class="set-item" id="local-list-choose" content-style="flex-direction: column">
|
||||
<n-flex justify="space-between">
|
||||
<div class="label">
|
||||
|
||||
@@ -232,6 +232,13 @@
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.lyricsBlur" class="set" :round="false" />
|
||||
</n-card>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">歌词排除内容</n-text>
|
||||
<n-text class="tip" :depth="3"> 歌词中包含的关键词将不会显示 </n-text>
|
||||
</div>
|
||||
<n-button type="primary" strong secondary @click="openLyricExclude">配置</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
<div class="set-list">
|
||||
<n-h3 prefix="bar"> Apple Music-like Lyrics </n-h3>
|
||||
@@ -328,6 +335,7 @@ import { useSettingStore, useStatusStore } from "@/stores";
|
||||
import { cloneDeep, isEqual } from "lodash-es";
|
||||
import { isElectron } from "@/utils/helper";
|
||||
import player from "@/utils/player";
|
||||
import { openLyricExclude } from "@/utils/modal";
|
||||
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
@@ -167,10 +167,28 @@
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.showPlaylistCount" 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.dynamicCover"
|
||||
:disabled="isLogin() !== 1"
|
||||
:round="false"
|
||||
class="set"
|
||||
/>
|
||||
</n-card>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">音乐频谱</n-text>
|
||||
<n-text class="tip" :depth="3">开启音乐频谱会极大影响性能,如遇问题请关闭</n-text>
|
||||
<n-text class="tip" :depth="3">
|
||||
{{
|
||||
isElectron
|
||||
? "开启音乐频谱会影响性能或音频输出切换等功能,如遇问题请关闭"
|
||||
: "开启可能会造成无法播放或其他问题,如遇任何问题请关闭"
|
||||
}}
|
||||
</n-text>
|
||||
</div>
|
||||
<n-switch
|
||||
class="set"
|
||||
@@ -208,6 +226,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from "naive-ui";
|
||||
import { useSettingStore } from "@/stores";
|
||||
import { isLogin } from "@/utils/auth";
|
||||
import { isElectron, renderOption } from "@/utils/helper";
|
||||
import { uniqBy } from "lodash";
|
||||
import player from "@/utils/player";
|
||||
@@ -233,30 +252,45 @@ const songLevelData = {
|
||||
value: "higher",
|
||||
},
|
||||
exhigh: {
|
||||
label: "极高 HQ",
|
||||
tip: "近 CD 品质的细节体验,最高 320kbps",
|
||||
label: "极高 (HQ)",
|
||||
tip: "近CD品质的细节体验,最高320kbps",
|
||||
value: "exhigh",
|
||||
},
|
||||
lossless: {
|
||||
label: "无损 SQ",
|
||||
tip: "高保真无损音质,最高 48kHz/16bit",
|
||||
label: "无损 (SQ)",
|
||||
tip: "高保真无损音质,最高48kHz/16bit",
|
||||
value: "lossless",
|
||||
},
|
||||
hires: {
|
||||
label: "高清臻音 Spatial Audio",
|
||||
tip: "环绕声体验,声音听感增强,96kHz/24bit",
|
||||
label: "高解析度无损 (Hi-Res)",
|
||||
tip: "更饱满清晰的高解析度音质,最高192kHz/24bit",
|
||||
value: "hires",
|
||||
},
|
||||
jyeffect: {
|
||||
label: "高清臻音 (Spatial Audio)",
|
||||
tip: "声音听感增强,96kHz/24bit",
|
||||
value: "jyeffect",
|
||||
},
|
||||
jymaster: {
|
||||
label: "超清母带 Master",
|
||||
label: "超清母带 (Master)",
|
||||
tip: "还原音频细节,192kHz/24bit",
|
||||
value: "jymaster",
|
||||
},
|
||||
sky: {
|
||||
label: "沉浸环绕声 Surround Audio",
|
||||
tip: "沉浸式体验,最高 5.1 声道",
|
||||
label: "沉浸环绕声 (Surround Audio)",
|
||||
tip: "沉浸式空间环绕音感,最高5.1声道",
|
||||
value: "sky",
|
||||
},
|
||||
vivid: {
|
||||
label: "臻音全景声 (Audio Vivid)",
|
||||
tip: "极致沉浸三维空间音频,最高7.1.4声道",
|
||||
value: "vivid",
|
||||
},
|
||||
dolby: {
|
||||
label: "杜比全景声 (Dolby Atmos)",
|
||||
tip: "杜比全景声音乐,沉浸式聆听体验",
|
||||
value: "dolby",
|
||||
},
|
||||
};
|
||||
|
||||
// 获取全部输出设备
|
||||
|
||||
127
src/components/UI/s-image.vue
Normal file
127
src/components/UI/s-image.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<!-- 图片组件 -->
|
||||
<template>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div ref="imgContainer" :key="src" class="s-image">
|
||||
<!-- 加载图片 -->
|
||||
<Transition name="fade">
|
||||
<img v-if="!isLoaded" :src="defaultSrc" class="loading" alt="loading" />
|
||||
</Transition>
|
||||
<!-- 真实图片 -->
|
||||
<img
|
||||
v-if="imgSrc"
|
||||
ref="imgRef"
|
||||
:src="imgSrc"
|
||||
:key="imgSrc"
|
||||
:alt="alt || 'image'"
|
||||
:class="['cover', { loaded: isLoaded }]"
|
||||
@load="imageLoaded"
|
||||
@error="imageError"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src: string | undefined;
|
||||
defaultSrc?: string;
|
||||
alt?: string;
|
||||
}>(),
|
||||
{
|
||||
defaultSrc: "/images/song.jpg?assest",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
// 加载完成
|
||||
load: [e: Event];
|
||||
// 加载失败
|
||||
error: [e: Event];
|
||||
// 可视状态变化
|
||||
"update:show": [show: boolean];
|
||||
}>();
|
||||
|
||||
// 图片数据
|
||||
const imgRef = ref<HTMLImageElement>();
|
||||
const imgSrc = ref<string>();
|
||||
const imgContainer = ref<HTMLImageElement>();
|
||||
|
||||
// 是否加载完成
|
||||
const isLoaded = ref<boolean>(false);
|
||||
|
||||
// 是否可视
|
||||
const isCanLook = useElementVisibility(imgContainer);
|
||||
|
||||
// 图片加载完成
|
||||
const imageLoaded = (e: Event) => {
|
||||
isLoaded.value = true;
|
||||
// 加载完成
|
||||
emit("load", e);
|
||||
};
|
||||
|
||||
// 图片加载失败
|
||||
const imageError = (e: Event) => {
|
||||
isLoaded.value = false;
|
||||
imgSrc.value = props.defaultSrc;
|
||||
// 加载失败
|
||||
emit("error", e);
|
||||
};
|
||||
|
||||
// 可视状态变化
|
||||
watch(
|
||||
isCanLook,
|
||||
(show) => {
|
||||
emit("update:show", show);
|
||||
if (show) imgSrc.value = props.src;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 监听 src 变化
|
||||
watch(
|
||||
() => props.src,
|
||||
(val) => {
|
||||
isLoaded.value = false;
|
||||
if (isCanLook.value) {
|
||||
imgSrc.value = val;
|
||||
} else {
|
||||
imgSrc.value = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.s-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.loading {
|
||||
position: absolute;
|
||||
// top: 0;
|
||||
// left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
.cover {
|
||||
// position: absolute;
|
||||
// top: 0;
|
||||
// left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
src/env.d.ts
vendored
3
src/env.d.ts
vendored
@@ -2,7 +2,6 @@
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
const component: DefineComponent<object, object, any>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@ import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
|
||||
import router from "@/router";
|
||||
// 自定义指令
|
||||
import { debounceDirective, throttleDirective, visibleDirective } from "@/utils/instruction";
|
||||
// VueVirtualScroller
|
||||
import VueVirtualScroller from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
// ipc
|
||||
import initIpc from "@/utils/initIpc";
|
||||
// 全局样式
|
||||
@@ -28,8 +25,6 @@ pinia.use(piniaPluginPersistedstate);
|
||||
app.use(pinia);
|
||||
// router
|
||||
app.use(router);
|
||||
// VueVirtualScroller
|
||||
app.use(VueVirtualScroller);
|
||||
// 自定义指令
|
||||
app.directive("debounce", debounceDirective);
|
||||
app.directive("throttle", throttleDirective);
|
||||
|
||||
@@ -9,22 +9,22 @@ const router: Router = createRouter({
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
// 保留滚动
|
||||
scrollBehavior(to, _, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(savedPosition);
|
||||
}, 300);
|
||||
});
|
||||
} else if (to.hash) {
|
||||
return {
|
||||
el: to.hash,
|
||||
behavior: "smooth",
|
||||
};
|
||||
} else {
|
||||
return { top: 0, left: 0, behavior: "smooth" };
|
||||
}
|
||||
},
|
||||
// scrollBehavior(to, _, savedPosition) {
|
||||
// if (savedPosition) {
|
||||
// return new Promise((resolve) => {
|
||||
// setTimeout(() => {
|
||||
// resolve(savedPosition);
|
||||
// }, 300);
|
||||
// });
|
||||
// } else if (to.hash) {
|
||||
// return {
|
||||
// el: to.hash,
|
||||
// behavior: "smooth",
|
||||
// };
|
||||
// } else {
|
||||
// return { top: 0, left: 0, behavior: "smooth" };
|
||||
// }
|
||||
// },
|
||||
});
|
||||
|
||||
// 前置守卫
|
||||
|
||||
@@ -141,6 +141,7 @@ const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "/radio",
|
||||
name: "radio",
|
||||
|
||||
beforeEnter: (to, _, next) => {
|
||||
if (!to.query.id) next({ path: "/403" });
|
||||
else next();
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { SongType, CoverType, UserDataType, UserLikeDataType, CatType } from "@/types/main";
|
||||
import type {
|
||||
SongType,
|
||||
CoverType,
|
||||
UserDataType,
|
||||
UserLikeDataType,
|
||||
CatType,
|
||||
LoginType,
|
||||
} from "@/types/main";
|
||||
import { playlistCatlist } from "@/api/playlist";
|
||||
import { cloneDeep, isEmpty } from "lodash-es";
|
||||
import { isLogin } from "@/utils/auth";
|
||||
@@ -8,11 +15,13 @@ import { formatCategoryList } from "@/utils/format";
|
||||
|
||||
interface ListState {
|
||||
playList: SongType[];
|
||||
originalPlayList: SongType[];
|
||||
historyList: SongType[];
|
||||
cloudPlayList: SongType[];
|
||||
searchHistory: string[];
|
||||
localPlayList: CoverType[];
|
||||
userLoginStatus: boolean;
|
||||
loginType: LoginType;
|
||||
userData: UserDataType;
|
||||
userLikeData: UserLikeDataType;
|
||||
likeSongsList: {
|
||||
@@ -42,11 +51,12 @@ const userDB = localforage.createInstance({
|
||||
storeName: "user",
|
||||
});
|
||||
|
||||
export const useDataStore = defineStore({
|
||||
id: "data",
|
||||
export const useDataStore = defineStore("data", {
|
||||
state: (): ListState => ({
|
||||
// 播放列表
|
||||
playList: [],
|
||||
// 原始播放列表
|
||||
originalPlayList: [],
|
||||
// 播放历史
|
||||
historyList: [],
|
||||
// 搜索历史
|
||||
@@ -55,8 +65,10 @@ export const useDataStore = defineStore({
|
||||
localPlayList: [],
|
||||
// 云盘歌单
|
||||
cloudPlayList: [],
|
||||
// 用户状态
|
||||
// 登录状态
|
||||
userLoginStatus: false,
|
||||
// 登录方式
|
||||
loginType: "qr",
|
||||
// 用户数据
|
||||
userData: {
|
||||
userId: 0,
|
||||
@@ -148,17 +160,48 @@ export const useDataStore = defineStore({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
// 保存原始播放列表
|
||||
async setOriginalPlayList(data: SongType[]): Promise<void> {
|
||||
const snapshot = cloneDeep(data);
|
||||
this.originalPlayList = snapshot;
|
||||
await musicDB.setItem("originalPlayList", snapshot);
|
||||
},
|
||||
// 获取原始播放列表
|
||||
async getOriginalPlayList(): Promise<SongType[] | null> {
|
||||
if (Array.isArray(this.originalPlayList) && this.originalPlayList.length > 0) {
|
||||
return this.originalPlayList;
|
||||
}
|
||||
const data = (await musicDB.getItem("originalPlayList")) as SongType[] | null;
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
this.originalPlayList = data;
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// 清除原始播放列表
|
||||
async clearOriginalPlayList(): Promise<void> {
|
||||
this.originalPlayList = [];
|
||||
await musicDB.setItem("originalPlayList", []);
|
||||
},
|
||||
// 新增下一首播放歌曲
|
||||
async setNextPlaySong(song: SongType, index: number): Promise<number> {
|
||||
// 移除重复的歌曲(如果存在)
|
||||
const playList = this.playList.filter((item) => item.id !== song.id);
|
||||
// 若为空,则直接添加
|
||||
if (this.playList.length === 0) {
|
||||
this.playList = [song];
|
||||
await musicDB.setItem("playList", cloneDeep(this.playList));
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 在当前播放位置之后插入歌曲
|
||||
const indexAdd = index + 1;
|
||||
playList.splice(indexAdd, 0, song);
|
||||
this.playList.splice(indexAdd, 0, song);
|
||||
// 移除重复的歌曲(如果存在)
|
||||
const playList = this.playList.filter((item, idx) => idx === indexAdd || item.id !== song.id);
|
||||
// 更新本地存储
|
||||
this.playList = playList;
|
||||
await musicDB.setItem("playList", cloneDeep(playList));
|
||||
return indexAdd;
|
||||
// 返回刚刚插入的歌曲索引
|
||||
return playList.findIndex((item) => item.id === song.id);
|
||||
},
|
||||
// 更改播放历史
|
||||
async setHistory(song: SongType) {
|
||||
@@ -229,6 +272,7 @@ export const useDataStore = defineStore({
|
||||
async clearUserData() {
|
||||
try {
|
||||
this.userLoginStatus = false;
|
||||
this.loginType = "qr";
|
||||
this.userData = {
|
||||
userId: 0,
|
||||
userType: 0,
|
||||
@@ -285,6 +329,6 @@ export const useDataStore = defineStore({
|
||||
persist: {
|
||||
key: "data-store",
|
||||
storage: localStorage,
|
||||
paths: ["userLoginStatus", "userData", "searchHistory", "catData"],
|
||||
pick: ["userLoginStatus", "loginType", "userData", "searchHistory", "catData"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,8 +34,7 @@ const defaultMusicData: SongType = {
|
||||
type: "song",
|
||||
};
|
||||
|
||||
export const useMusicStore = defineStore({
|
||||
id: "music",
|
||||
export const useMusicStore = defineStore("music", {
|
||||
state: (): MusicState => ({
|
||||
// 当前播放歌曲
|
||||
playSong: { ...defaultMusicData },
|
||||
@@ -86,7 +85,7 @@ export const useMusicStore = defineStore({
|
||||
actions: {
|
||||
// 恢复默认音乐数据
|
||||
resetMusicData() {
|
||||
this.playSong = defaultMusicData;
|
||||
this.playSong = { ...defaultMusicData };
|
||||
this.songLyric = {
|
||||
lrcData: [],
|
||||
yrcData: [],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { keywords } from "@/assets/data/exclude";
|
||||
|
||||
interface SettingState {
|
||||
themeMode: "light" | "dark" | "auto";
|
||||
@@ -81,10 +82,14 @@ interface SettingState {
|
||||
useRealIP: boolean;
|
||||
realIP: string;
|
||||
fullPlayerCache: boolean;
|
||||
scrobbleSong: boolean;
|
||||
dynamicCover: boolean;
|
||||
useKeepAlive: boolean;
|
||||
excludeKeywords: string[];
|
||||
showDefaultLocalPath: boolean;
|
||||
}
|
||||
|
||||
export const useSettingStore = defineStore({
|
||||
id: "setting",
|
||||
export const useSettingStore = defineStore("setting", {
|
||||
state: (): SettingState => ({
|
||||
// 个性化
|
||||
themeMode: "auto", // 明暗模式
|
||||
@@ -106,6 +111,7 @@ export const useSettingStore = defineStore({
|
||||
checkUpdateOnStart: true, // 启动时检查更新
|
||||
preventSleep: false, // 是否禁止休眠
|
||||
fullPlayerCache: false, // 全屏播放器缓存
|
||||
useKeepAlive: true, // 使用 keep-alive
|
||||
// 播放
|
||||
songLevel: "exhigh", // 音质
|
||||
playDevice: "default", // 播放设备
|
||||
@@ -123,6 +129,8 @@ export const useSettingStore = defineStore({
|
||||
smtcOpen: true, // 是否开启 SMTC
|
||||
smtcOutputHighQualityCover: false, // 是否输出高清封面
|
||||
playSongDemo: false, // 是否播放试听歌曲
|
||||
scrobbleSong: false, // 是否打卡
|
||||
dynamicCover: true, // 动态封面
|
||||
// 歌词
|
||||
lyricFontSize: 46, // 歌词大小
|
||||
lyricTranFontSize: 22, // 歌词翻译大小
|
||||
@@ -138,8 +146,10 @@ export const useSettingStore = defineStore({
|
||||
lyricsBlur: false, // 歌词模糊
|
||||
lyricsScrollPosition: "start", // 歌词滚动位置
|
||||
lrcMousePause: false, // 鼠标悬停暂停
|
||||
excludeKeywords: keywords, // 排除歌词关键字
|
||||
// 本地
|
||||
localFilesPath: [],
|
||||
showDefaultLocalPath: true, // 显示默认本地路径
|
||||
localSeparators: ["/", "&"],
|
||||
showLocalCover: true,
|
||||
// 下载
|
||||
@@ -161,11 +171,13 @@ export const useSettingStore = defineStore({
|
||||
setThemeMode(mode?: "auto" | "light" | "dark") {
|
||||
// 若未传入
|
||||
if (mode === undefined) {
|
||||
this.themeMode === "auto"
|
||||
? (this.themeMode = "light")
|
||||
: this.themeMode === "light"
|
||||
? (this.themeMode = "dark")
|
||||
: (this.themeMode = "auto");
|
||||
if (this.themeMode === "auto") {
|
||||
this.themeMode = "light";
|
||||
} else if (this.themeMode === "light") {
|
||||
this.themeMode = "dark";
|
||||
} else {
|
||||
this.themeMode = "auto";
|
||||
}
|
||||
} else {
|
||||
this.themeMode = mode;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ interface ShortcutStore {
|
||||
};
|
||||
}
|
||||
|
||||
export const useShortcutStore = defineStore({
|
||||
id: "shortcut",
|
||||
export const useShortcutStore = defineStore("shortcut", {
|
||||
state: (): ShortcutStore => ({
|
||||
// 全局快捷键开启
|
||||
globalOpen: true,
|
||||
|
||||
@@ -29,6 +29,7 @@ interface StatusState {
|
||||
lyricIndex: number;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
chorus: number;
|
||||
progress: number;
|
||||
currentTimeOffset: number;
|
||||
playUblock: boolean;
|
||||
@@ -37,10 +38,10 @@ interface StatusState {
|
||||
showDesktopLyric: boolean;
|
||||
showPlayerComment: boolean;
|
||||
personalFmMode: boolean;
|
||||
updateCheck: boolean;
|
||||
}
|
||||
|
||||
export const useStatusStore = defineStore({
|
||||
id: "status",
|
||||
export const useStatusStore = defineStore("status", {
|
||||
state: (): StatusState => ({
|
||||
// 菜单折叠状态
|
||||
menuCollapsed: false,
|
||||
@@ -65,6 +66,8 @@ export const useStatusStore = defineStore({
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
progress: 0,
|
||||
// 副歌时间
|
||||
chorus: 0,
|
||||
// 进度偏移
|
||||
currentTimeOffset: 0,
|
||||
// 封面主题
|
||||
@@ -74,7 +77,7 @@ export const useStatusStore = defineStore({
|
||||
// 音乐频谱数据
|
||||
spectrumsData: [],
|
||||
// 当前播放索引
|
||||
playIndex: 0,
|
||||
playIndex: -1,
|
||||
// 歌词播放索引
|
||||
lyricIndex: -1,
|
||||
// 默认倍速
|
||||
@@ -97,14 +100,49 @@ export const useStatusStore = defineStore({
|
||||
showDesktopLyric: false,
|
||||
// 播放器评论
|
||||
showPlayerComment: false,
|
||||
// 更新检查
|
||||
updateCheck: false,
|
||||
}),
|
||||
getters: {},
|
||||
getters: {
|
||||
// 播放音量图标
|
||||
playVolumeIcon(state) {
|
||||
const volume = state.playVolume;
|
||||
return volume === 0
|
||||
? "VolumeOff"
|
||||
: volume < 0.4
|
||||
? "VolumeMute"
|
||||
: volume < 0.7
|
||||
? "VolumeDown"
|
||||
: "VolumeUp";
|
||||
},
|
||||
// 播放模式图标
|
||||
playModeIcon(state) {
|
||||
const mode = state.playSongMode;
|
||||
return state.playHeartbeatMode
|
||||
? "HeartBit"
|
||||
: mode === "repeat"
|
||||
? "Repeat"
|
||||
: mode === "repeat-once"
|
||||
? "RepeatSong"
|
||||
: "Shuffle";
|
||||
},
|
||||
// 音量百分比
|
||||
playVolumePercent(state) {
|
||||
return Math.round(state.playVolume * 100);
|
||||
},
|
||||
// 播放器主色
|
||||
mainColor(state) {
|
||||
const mainColor = state.songCoverTheme?.main;
|
||||
if (!mainColor) return "239, 239, 239";
|
||||
return `${mainColor.r}, ${mainColor.g}, ${mainColor.b}`;
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
// 持久化
|
||||
persist: {
|
||||
key: "status-store",
|
||||
storage: localStorage,
|
||||
paths: [
|
||||
pick: [
|
||||
"menuCollapsed",
|
||||
"currentTime",
|
||||
"duration",
|
||||
|
||||
6
src/types/main.d.ts
vendored
6
src/types/main.d.ts
vendored
@@ -1,5 +1,4 @@
|
||||
import { sortOptions } from "@/utils/helper";
|
||||
import { songLevelData } from "@/utils/meta";
|
||||
import { songLevelData, sortOptions } from "@/utils/meta";
|
||||
|
||||
export type MetaData = {
|
||||
id: number;
|
||||
@@ -235,3 +234,6 @@ interface UpdateInfoType {
|
||||
releaseNotes: string;
|
||||
prerelease: boolean;
|
||||
}
|
||||
|
||||
// 登录方式
|
||||
export type LoginType = "qr" | "phone" | "cookie" | "uid";
|
||||
|
||||
@@ -15,20 +15,28 @@ import { likeSong } from "@/api/song";
|
||||
import { formatCoverList, formatArtistsList, formatSongsList } from "@/utils/format";
|
||||
import { useDataStore, useMusicStore } from "@/stores";
|
||||
import { logout, refreshLogin } from "@/api/login";
|
||||
import { openUserLogin } from "./modal";
|
||||
import { debounce } from "lodash-es";
|
||||
import { debounce, isFunction } from "lodash-es";
|
||||
import { isBeforeSixAM } from "./time";
|
||||
import { dailyRecommend } from "@/api/rec";
|
||||
import { isElectron } from "./helper";
|
||||
import { playlistTracks } from "@/api/playlist";
|
||||
import { likePlaylist, playlistTracks } from "@/api/playlist";
|
||||
import { likeArtist } from "@/api/artist";
|
||||
import { radioSub } from "@/api/radio";
|
||||
|
||||
// 是否登录
|
||||
export const isLogin = () => !!getCookie("MUSIC_U");
|
||||
/**
|
||||
* 用户是否登录
|
||||
* @returns 0 - 未登录 / 1 - 正常登录 / 2 - UID 登录
|
||||
*/
|
||||
export const isLogin = (): 0 | 1 | 2 => {
|
||||
const dataStore = useDataStore();
|
||||
if (!dataStore.userLoginStatus) return 0;
|
||||
if (dataStore.loginType === "uid") return 2;
|
||||
return getCookie("MUSIC_U") ? 1 : 0;
|
||||
};
|
||||
|
||||
// 退出登录
|
||||
export const toLogout = async () => {
|
||||
const dataStore = useDataStore();
|
||||
// 退出登录
|
||||
await logout();
|
||||
// 去除 cookie
|
||||
removeCookie("MUSIC_U");
|
||||
@@ -86,7 +94,6 @@ export const updateUserData = async () => {
|
||||
subPlaylistCount: subcountData.subPlaylistCount,
|
||||
createdPlaylistCount: subcountData.createdPlaylistCount,
|
||||
};
|
||||
|
||||
// 获取用户喜欢数据
|
||||
const allUserLikeResult = await Promise.allSettled([
|
||||
updateUserLikeSongs(),
|
||||
@@ -100,8 +107,6 @@ export const updateUserData = async () => {
|
||||
]);
|
||||
// 若部分失败
|
||||
const hasFailed = allUserLikeResult.some((result) => result.status === "rejected");
|
||||
console.log(allUserLikeResult);
|
||||
|
||||
if (hasFailed) throw new Error("Failed to update some user data");
|
||||
} catch (error) {
|
||||
console.error("❌ Error updating user data:", error);
|
||||
@@ -109,6 +114,34 @@ export const updateUserData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 更新用户信息 - 特殊登录模式
|
||||
export const updateSpecialUserData = async (userData?: any) => {
|
||||
try {
|
||||
const dataStore = useDataStore();
|
||||
if (!userData) {
|
||||
const result = await userDetail(dataStore.userData.userId);
|
||||
userData = result?.profile;
|
||||
}
|
||||
// 更改用户信息
|
||||
dataStore.userData = {
|
||||
userId: userData.userId,
|
||||
userType: userData.userType,
|
||||
vipType: userData.vipType,
|
||||
name: userData.nickname,
|
||||
level: userData.level,
|
||||
avatarUrl: userData.avatarUrl,
|
||||
backgroundUrl: userData.backgroundUrl,
|
||||
createTime: userData.createTime,
|
||||
createDays: userData.createDays,
|
||||
};
|
||||
// 获取用户喜欢数据
|
||||
await updateUserLikePlaylist();
|
||||
} catch (error) {
|
||||
console.error("❌ Error updating special user data:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 更新用户喜欢歌曲
|
||||
export const updateUserLikeSongs = async () => {
|
||||
const dataStore = useDataStore();
|
||||
@@ -122,6 +155,11 @@ export const updateUserLikePlaylist = async () => {
|
||||
const dataStore = useDataStore();
|
||||
const userId = dataStore.userData.userId;
|
||||
if (!isLogin() || !userId) return;
|
||||
if (dataStore.loginType === "uid") {
|
||||
const result = await userPlaylist(30, 0, userId);
|
||||
dataStore.setUserLikeData("playlists", formatCoverList(result.playlist));
|
||||
return;
|
||||
}
|
||||
// 计算数量
|
||||
const { createdPlaylistCount, subPlaylistCount } = dataStore.userData;
|
||||
const number = (createdPlaylistCount || 0) + (subPlaylistCount || 0) || 50;
|
||||
@@ -160,7 +198,10 @@ export const toLikeSong = debounce(
|
||||
async (song: SongType, like: boolean) => {
|
||||
if (!isLogin()) {
|
||||
window.$message.warning("请登录后使用");
|
||||
openUserLogin();
|
||||
return;
|
||||
}
|
||||
if (isLogin() === 2) {
|
||||
window.$message.warning("该登录模式暂不支持该操作");
|
||||
return;
|
||||
}
|
||||
const dataStore = useDataStore();
|
||||
@@ -195,6 +236,84 @@ export const toLikeSong = debounce(
|
||||
{ leading: true, trailing: false },
|
||||
);
|
||||
|
||||
// 收藏/取消收藏歌单
|
||||
export const toLikePlaylist = debounce(
|
||||
async (id: number, like: boolean) => {
|
||||
if (!id) return;
|
||||
if (!isLogin()) {
|
||||
window.$message.warning("请登录后使用");
|
||||
return;
|
||||
}
|
||||
if (isLogin() === 2) {
|
||||
window.$message.warning("该登录模式暂不支持该操作");
|
||||
return;
|
||||
}
|
||||
const { code } = await likePlaylist(id, like ? 1 : 2);
|
||||
if (code === 200) {
|
||||
window.$message.success((like ? "收藏" : "取消收藏") + "歌单成功");
|
||||
// 更新
|
||||
await updateUserLikePlaylist();
|
||||
} else {
|
||||
window.$message.success((like ? "收藏" : "取消收藏") + "歌单失败,请重试");
|
||||
return;
|
||||
}
|
||||
},
|
||||
300,
|
||||
{ leading: true, trailing: false },
|
||||
);
|
||||
|
||||
// 收藏/取消收藏歌手
|
||||
export const toLikeArtist = debounce(
|
||||
async (id: number, like: boolean) => {
|
||||
if (!id) return;
|
||||
if (!isLogin()) {
|
||||
window.$message.warning("请登录后使用");
|
||||
return;
|
||||
}
|
||||
if (isLogin() === 2) {
|
||||
window.$message.warning("该登录模式暂不支持该操作");
|
||||
return;
|
||||
}
|
||||
const { code } = await likeArtist(id, like ? 1 : 2);
|
||||
if (code === 200) {
|
||||
window.$message.success((like ? "收藏" : "取消收藏") + "歌手成功");
|
||||
// 更新
|
||||
await updateUserLikeArtists();
|
||||
} else {
|
||||
window.$message.success((like ? "收藏" : "取消收藏") + "歌手失败,请重试");
|
||||
return;
|
||||
}
|
||||
},
|
||||
300,
|
||||
{ leading: true, trailing: false },
|
||||
);
|
||||
|
||||
// 订阅/取消订阅播客
|
||||
export const toSubRadio = debounce(
|
||||
async (id: number, like: boolean) => {
|
||||
if (!id) return;
|
||||
if (!isLogin()) {
|
||||
window.$message.warning("请登录后使用");
|
||||
return;
|
||||
}
|
||||
if (isLogin() === 2) {
|
||||
window.$message.warning("该登录模式暂不支持该操作");
|
||||
return;
|
||||
}
|
||||
const { code } = await radioSub(id, like ? 1 : 0);
|
||||
if (code === 200) {
|
||||
window.$message.success((like ? "订阅" : "取消订阅") + "播客成功");
|
||||
// 更新
|
||||
await updateUserLikeDjs();
|
||||
} else {
|
||||
window.$message.success((like ? "订阅" : "取消订阅") + "播客失败,请重试");
|
||||
return;
|
||||
}
|
||||
},
|
||||
300,
|
||||
{ leading: true, trailing: false },
|
||||
);
|
||||
|
||||
// 循环获取用户喜欢数据
|
||||
const setUserLikeDataLoop = async <T>(
|
||||
apiFunction: (limit: number, offset: number) => Promise<{ data: any[]; count: number }>,
|
||||
@@ -279,7 +398,7 @@ export const deleteSongs = async (pid: number, ids: number[], callback?: () => v
|
||||
window.$message.error(result.body?.message || "删除歌曲失败,请重试");
|
||||
return;
|
||||
}
|
||||
callback && callback();
|
||||
if (isFunction(callback)) callback();
|
||||
window.$message.success("删除成功");
|
||||
} else {
|
||||
window.$message.error(result?.message || "删除歌曲失败,请重试");
|
||||
|
||||
@@ -13,13 +13,21 @@ export const removeCookie = (key: string) => {
|
||||
|
||||
// 设置 Cookie
|
||||
export const setCookies = (cookieValue: string) => {
|
||||
const cookies = cookieValue.split(";;");
|
||||
const cookies = cookieValue.split(";");
|
||||
const date = new Date();
|
||||
// 永不过期
|
||||
date.setFullYear(date.getFullYear() + 50);
|
||||
const expires = `expires=${date.toUTCString()}`;
|
||||
// 写入
|
||||
cookies.forEach((cookie) => {
|
||||
document.cookie = cookie;
|
||||
// document.cookie = cookie;
|
||||
const cookieParts = cookie.split(";");
|
||||
const nameValuePair = cookieParts[0].split("=");
|
||||
const name = nameValuePair[0].trim();
|
||||
const value = nameValuePair[1].trim();
|
||||
const name = nameValuePair[0]?.trim();
|
||||
const value = nameValuePair[1]?.trim();
|
||||
console.info(`name: ${name}, value: ${value}`);
|
||||
// 设置 cookie
|
||||
document.cookie = `${name}=${value}; ${expires}; path=/`;
|
||||
// 保存 cookie
|
||||
localStorage.setItem(`cookie-${name}`, value);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SongType, CoverType, ArtistType, CommentType, MetaData, CatType } from "@/types/main";
|
||||
import { msToTime } from "./time";
|
||||
import { isArray } from "lodash-es";
|
||||
import { flatMap, isArray, uniqBy } from "lodash-es";
|
||||
|
||||
type CoverDataType = {
|
||||
cover: string;
|
||||
@@ -81,7 +81,10 @@ export const formatCoverList = (data: any[]): CoverType[] => {
|
||||
const creator = isArray(item.creator) ? item.creator[0] : item.creator;
|
||||
// 获取歌手信息
|
||||
const artists = (): string | MetaData[] => {
|
||||
const artistData = [item.artist, item.artists, item.ar].flat().filter(Boolean);
|
||||
const artistData = uniqBy(
|
||||
flatMap([item.artist, item.artists, item.ar]).filter(Boolean),
|
||||
"id",
|
||||
);
|
||||
if (artistData.length === 0) return "";
|
||||
return artistData.map((artist) => ({
|
||||
id: artist?.id,
|
||||
|
||||
@@ -273,9 +273,11 @@ export const changeLocalPath = async (delIndex?: number) => {
|
||||
// 检查是否为子文件夹
|
||||
const defaultMusicPath = await window.electron.ipcRenderer.invoke("get-default-dir", "music");
|
||||
const allPath = [defaultMusicPath, ...settingStore.localFilesPath];
|
||||
const isSubfolder = allPath.some((existingPath) => {
|
||||
return selectedDir.startsWith(existingPath);
|
||||
});
|
||||
const isSubfolder = await window.electron.ipcRenderer.invoke(
|
||||
"check-if-subfolder",
|
||||
allPath,
|
||||
selectedDir,
|
||||
);
|
||||
if (!isSubfolder) {
|
||||
settingStore.localFilesPath.push(selectedDir);
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useEventListener } from "@vueuse/core";
|
||||
import { openUserAgreement } from "@/utils/modal";
|
||||
import { debounce } from "lodash-es";
|
||||
import { isElectron } from "./helper";
|
||||
import packageJson from "@/../package.json";
|
||||
import player from "@/utils/player";
|
||||
import log from "./log";
|
||||
|
||||
// 应用初始化时需要执行的操作
|
||||
const init = async () => {
|
||||
@@ -13,6 +15,8 @@ const init = async () => {
|
||||
const settingStore = useSettingStore();
|
||||
const shortcutStore = useShortcutStore();
|
||||
|
||||
printVersion();
|
||||
|
||||
// 用户协议
|
||||
openUserAgreement();
|
||||
|
||||
@@ -22,7 +26,10 @@ const init = async () => {
|
||||
// 加载数据
|
||||
await dataStore.loadData();
|
||||
// 初始化播放器
|
||||
player.initPlayer(settingStore.autoPlay);
|
||||
player.initPlayer(
|
||||
settingStore.autoPlay,
|
||||
settingStore.memoryLastSeek ? statusStore.currentTime : 0,
|
||||
);
|
||||
// 同步播放模式
|
||||
player.playModeSyncIpc();
|
||||
|
||||
@@ -80,10 +87,10 @@ const keyDownEvent = debounce((event: KeyboardEvent) => {
|
||||
player.playOrPause();
|
||||
break;
|
||||
case "playPrev":
|
||||
player.nextOrPrev("next");
|
||||
player.nextOrPrev("prev");
|
||||
break;
|
||||
case "playNext":
|
||||
player.nextOrPrev("prev");
|
||||
player.nextOrPrev("next");
|
||||
break;
|
||||
case "volumeUp":
|
||||
player.setVolume("up");
|
||||
@@ -101,4 +108,10 @@ const keyDownEvent = debounce((event: KeyboardEvent) => {
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// 版本输出
|
||||
const printVersion = async () => {
|
||||
log.success(`🚀 ${packageJson.version}`, packageJson.productName);
|
||||
log.info(`👤 ${packageJson.author}`, packageJson.github);
|
||||
};
|
||||
|
||||
export default init;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { isElectron } from "./helper";
|
||||
import { openUpdateApp } from "./modal";
|
||||
import { useMusicStore, useDataStore } from "@/stores";
|
||||
import player from "./player";
|
||||
import { useMusicStore, useDataStore, useStatusStore } from "@/stores";
|
||||
import { toLikeSong } from "./auth";
|
||||
import player from "./player";
|
||||
|
||||
// 关闭更新状态
|
||||
const closeUpdateStatus = () => {
|
||||
const statusStore = useStatusStore();
|
||||
statusStore.updateCheck = false;
|
||||
};
|
||||
|
||||
// 全局 IPC 事件
|
||||
const initIpc = () => {
|
||||
@@ -35,13 +41,18 @@ const initIpc = () => {
|
||||
window.electron.ipcRenderer.on("closeDesktopLyric", () => player.toggleDesktopLyric());
|
||||
// 无更新
|
||||
window.electron.ipcRenderer.on("update-not-available", () => {
|
||||
closeUpdateStatus();
|
||||
window.$message.success("当前已是最新版本");
|
||||
});
|
||||
// 有更新
|
||||
window.electron.ipcRenderer.on("update-available", (_, info) => openUpdateApp(info));
|
||||
window.electron.ipcRenderer.on("update-available", (_, info) => {
|
||||
closeUpdateStatus();
|
||||
openUpdateApp(info);
|
||||
});
|
||||
// 更新错误
|
||||
window.electron.ipcRenderer.on("update-error", (_, error) => {
|
||||
console.error("Error updating:", error);
|
||||
closeUpdateStatus();
|
||||
window.$message.error("更新过程出现错误");
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -53,29 +53,6 @@ export const throttleDirective = {
|
||||
};
|
||||
|
||||
// 元素可见
|
||||
// export const visibleDirective = {
|
||||
// mounted(el: HTMLElement, binding: any) {
|
||||
// const { modifiers, value } = binding;
|
||||
|
||||
// const { stop } = useIntersectionObserver(
|
||||
// el,
|
||||
// ([entry]) => {
|
||||
// if (entry.isIntersecting) {
|
||||
// el.classList.add("visible");
|
||||
// // 触发回调
|
||||
// if (typeof value === "function") value(entry);
|
||||
// // 只触发一次
|
||||
// if (modifiers.once) stop();
|
||||
// } else if (!modifiers.once) {
|
||||
// el.classList.remove("visible");
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// threshold: 0.1,
|
||||
// },
|
||||
// );
|
||||
// },
|
||||
// };
|
||||
export const visibleDirective = {
|
||||
mounted(el: HTMLElement, binding: any) {
|
||||
const { modifiers, value } = binding;
|
||||
|
||||
97
src/utils/log.ts
Normal file
97
src/utils/log.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 美化打印实现方法
|
||||
* https://juejin.cn/post/7371716384847364147
|
||||
*/
|
||||
const log = () => {
|
||||
const isEmpty = (value: any) => {
|
||||
return value == null || value === undefined || value === "";
|
||||
};
|
||||
const prettyPrint = (title: string, text: string, color: string) => {
|
||||
console.info(
|
||||
`%c ${title} %c ${text} %c`,
|
||||
`background:${color};border:1px solid ${color}; padding: 1px; border-radius: 2px 0 0 2px; color: #fff;`,
|
||||
`border:1px solid ${color}; padding: 1px; border-radius: 0 2px 2px 0; color: ${color};`,
|
||||
"background:transparent",
|
||||
);
|
||||
};
|
||||
const info = (textOrTitle: string, content = "") => {
|
||||
const title = isEmpty(content) ? "Info" : textOrTitle;
|
||||
const text = isEmpty(content) ? textOrTitle : content;
|
||||
prettyPrint(title, text, "#909399");
|
||||
};
|
||||
const error = (textOrTitle: string, content = "") => {
|
||||
const title = isEmpty(content) ? "Error" : textOrTitle;
|
||||
const text = isEmpty(content) ? textOrTitle : content;
|
||||
prettyPrint(title, text, "#F56C6C");
|
||||
};
|
||||
const warning = (textOrTitle: string, content = "") => {
|
||||
const title = isEmpty(content) ? "Warning" : textOrTitle;
|
||||
const text = isEmpty(content) ? textOrTitle : content;
|
||||
prettyPrint(title, text, "#E6A23C");
|
||||
};
|
||||
const success = (textOrTitle: string, content = "") => {
|
||||
const title = isEmpty(content) ? "Success " : textOrTitle;
|
||||
const text = isEmpty(content) ? textOrTitle : content;
|
||||
prettyPrint(title, text, "#67C23A");
|
||||
};
|
||||
const table = () => {
|
||||
const data = [
|
||||
{ id: 1, name: "Alice", age: 25 },
|
||||
{ id: 2, name: "Bob", age: 30 },
|
||||
{ id: 3, name: "Charlie", age: 35 },
|
||||
];
|
||||
console.info(
|
||||
"%c id%c name%c age",
|
||||
"color: white; background-color: black; padding: 2px 10px;",
|
||||
"color: white; background-color: black; padding: 2px 10px;",
|
||||
"color: white; background-color: black; padding: 2px 10px;",
|
||||
);
|
||||
|
||||
data.forEach((row: any) => {
|
||||
console.info(
|
||||
`%c ${row.id} %c ${row.name} %c ${row.age} `,
|
||||
"color: black; background-color: lightgray; padding: 2px 10px;",
|
||||
"color: black; background-color: lightgray; padding: 2px 10px;",
|
||||
"color: black; background-color: lightgray; padding: 2px 10px;",
|
||||
);
|
||||
});
|
||||
};
|
||||
const picture = (url: string, scale = 1) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
const c = document.createElement("canvas");
|
||||
const ctx = c.getContext("2d");
|
||||
if (ctx) {
|
||||
c.width = img.width;
|
||||
c.height = img.height;
|
||||
ctx.fillStyle = "red";
|
||||
ctx.fillRect(0, 0, c.width, c.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const dataUri = c.toDataURL("image/png");
|
||||
|
||||
console.info(
|
||||
`%c sup?`,
|
||||
`font-size: 1px;
|
||||
padding: ${Math.floor((img.height * scale) / 2)}px ${Math.floor((img.width * scale) / 2)}px;background-image: url(${dataUri});
|
||||
background-repeat: no-repeat;
|
||||
background-size: ${img.width * scale}px ${img.height * scale}px;
|
||||
color: transparent;`,
|
||||
);
|
||||
}
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
// retu;
|
||||
return {
|
||||
info,
|
||||
error,
|
||||
warning,
|
||||
success,
|
||||
picture,
|
||||
table,
|
||||
};
|
||||
};
|
||||
|
||||
export default log();
|
||||
@@ -1,9 +1,14 @@
|
||||
import { LyricLine, parseLrc, parseYrc } from "@applemusic-like-lyrics/lyric";
|
||||
import { keywords } from "@/assets/data/exclude";
|
||||
import type { LyricType } from "@/types/main";
|
||||
import { useMusicStore } from "@/stores";
|
||||
import { useMusicStore, useSettingStore } from "@/stores";
|
||||
import { msToS } from "./time";
|
||||
|
||||
// 歌词排除内容
|
||||
const getExcludeKeywords = () => {
|
||||
const settingStore = useSettingStore();
|
||||
return settingStore.excludeKeywords;
|
||||
};
|
||||
|
||||
// 恢复默认
|
||||
export const resetSongLyric = () => {
|
||||
const musicStore = useMusicStore();
|
||||
@@ -77,7 +82,7 @@ export const parseLrcData = (lrcData: LyricLine[]): LyricType[] => {
|
||||
const time = msToS(words[0].startTime);
|
||||
const content = words[0].word.trim();
|
||||
// 排除内容
|
||||
if (!content || keywords.some((keyword) => content.includes(keyword))) {
|
||||
if (!content || getExcludeKeywords().some((keyword) => content.includes(keyword))) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -113,7 +118,7 @@ export const parseYrcData = (yrcData: LyricLine[]): LyricType[] => {
|
||||
.map((word) => word.content + (word.endsWithSpace ? " " : ""))
|
||||
.join("");
|
||||
// 排除内容
|
||||
if (!contentStr || keywords.some((keyword) => contentStr.includes(keyword))) {
|
||||
if (!contentStr || getExcludeKeywords().some((keyword) => contentStr.includes(keyword))) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { SongLevelType } from "@/types/main";
|
||||
import type { ImageRenderToolbarProps } from "naive-ui";
|
||||
import { compact, findKey, keys, pick, takeWhile } from "lodash-es";
|
||||
import { renderIcon } from "./helper";
|
||||
|
||||
// 音质数据
|
||||
export const songLevelData = {
|
||||
@@ -63,16 +62,16 @@ export function getLevelsUpTo(level: string): Partial<typeof songLevelData> {
|
||||
|
||||
// 排序选项
|
||||
export const sortOptions = {
|
||||
default: { name: "默认排序", show: "all", icon: renderIcon("Sort") },
|
||||
titleAZ: { name: "标题升序( A - Z )", show: "all", icon: renderIcon("SortAZ") },
|
||||
titleZA: { name: "标题降序( Z - A )", show: "all", icon: renderIcon("SortZA") },
|
||||
arAZ: { name: "歌手升序( A - Z )", show: "song", icon: renderIcon("SortAZ") },
|
||||
arZA: { name: "歌手降序( Z - A )", show: "song", icon: renderIcon("SortZA") },
|
||||
timeUp: { name: "时长升序", show: "all", icon: renderIcon("SortClockUp") },
|
||||
timeDown: { name: "时长降序", show: "all", icon: renderIcon("SortClockDown") },
|
||||
dateUp: { name: "日期升序", show: "radio", icon: renderIcon("SortDateUp") },
|
||||
dateDown: { name: "日期降序", show: "radio", icon: renderIcon("SortDateDown") },
|
||||
};
|
||||
default: { name: "默认排序", show: "all", icon: "Sort" },
|
||||
titleAZ: { name: "标题升序( A - Z )", show: "all", icon: "SortAZ" },
|
||||
titleZA: { name: "标题降序( Z - A )", show: "all", icon: "SortZA" },
|
||||
arAZ: { name: "歌手升序( A - Z )", show: "song", icon: "SortAZ" },
|
||||
arZA: { name: "歌手降序( Z - A )", show: "song", icon: "SortZA" },
|
||||
timeUp: { name: "时长升序", show: "all", icon: "SortClockUp" },
|
||||
timeDown: { name: "时长降序", show: "all", icon: "SortClockDown" },
|
||||
dateUp: { name: "日期升序", show: "radio", icon: "SortDateUp" },
|
||||
dateDown: { name: "日期降序", show: "radio", icon: "SortDateDown" },
|
||||
} as const;
|
||||
|
||||
// 自定义图片工具栏
|
||||
export const renderToolbar = ({ nodes }: ImageRenderToolbarProps) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isLogin } from "./auth";
|
||||
import { isArray, isFunction } from "lodash-es";
|
||||
import { useDataStore } from "@/stores";
|
||||
import router from "@/router";
|
||||
import Login from "@/components/Modal/Login.vue";
|
||||
import Login from "@/components/Modal/Login/Login.vue";
|
||||
import JumpArtist from "@/components/Modal/JumpArtist.vue";
|
||||
import UserAgreement from "@/components/Modal/UserAgreement.vue";
|
||||
import SongInfoEditor from "@/components/Modal/SongInfoEditor.vue";
|
||||
@@ -16,6 +16,7 @@ import UpdatePlaylist from "@/components/Modal/UpdatePlaylist.vue";
|
||||
import DownloadSong from "@/components/Modal/DownloadSong.vue";
|
||||
import MainSetting from "@/components/Setting/MainSetting.vue";
|
||||
import UpdateApp from "@/components/Modal/UpdateApp.vue";
|
||||
import ExcludeKeywords from "@/components/Modal/ExcludeKeywords.vue";
|
||||
|
||||
// 用户协议
|
||||
export const openUserAgreement = () => {
|
||||
@@ -101,7 +102,7 @@ export const openSongInfoEditor = (song: SongType) => {
|
||||
// 添加到歌单
|
||||
export const openPlaylistAdd = (data: SongType[], isLocal: boolean) => {
|
||||
if (!data.length) return window.$message.warning("请正确选择歌曲");
|
||||
if (!isLogin()) return openUserLogin();
|
||||
if (!isLogin() && !isLocal) return openUserLogin();
|
||||
const modal = window.$modal.create({
|
||||
preset: "card",
|
||||
transformOrigin: "center",
|
||||
@@ -176,7 +177,7 @@ export const openUpdatePlaylist = (id: number, data: CoverType, func: () => Prom
|
||||
onSuccess: () => {
|
||||
modal.destroy();
|
||||
// 触发回调
|
||||
isFunction(func) && func();
|
||||
if (isFunction(func)) func();
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -233,3 +234,17 @@ export const openUpdateApp = (data: UpdateInfoType) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 歌词排除内容
|
||||
export const openLyricExclude = () => {
|
||||
window.$modal.create({
|
||||
preset: "card",
|
||||
transformOrigin: "center",
|
||||
autoFocus: false,
|
||||
style: { width: "600px" },
|
||||
title: "歌词排除内容",
|
||||
content: () => {
|
||||
return h(ExcludeKeywords);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,15 +4,14 @@ import { Howl, Howler } from "howler";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores";
|
||||
import { parsedLyricsData, resetSongLyric, parseLocalLyric } from "./lyric";
|
||||
import { songUrl, unlockSongUrl, songLyric } from "@/api/song";
|
||||
import { songUrl, unlockSongUrl, songLyric, songChorus } from "@/api/song";
|
||||
import { getCoverColorData } from "@/utils/color";
|
||||
import { calculateProgress } from "./time";
|
||||
import { isElectron, isDev, sleep } from "./helper";
|
||||
import { isElectron, isDev } from "./helper";
|
||||
import { heartRateList } from "@/api/playlist";
|
||||
import { formatSongsList } from "./format";
|
||||
import { isLogin } from "./auth";
|
||||
import { openUserLogin } from "./modal";
|
||||
import { scrobble } from "@/api/user";
|
||||
import { personalFm, personalFmToTrash } from "@/api/rec";
|
||||
import blob from "./blob";
|
||||
|
||||
@@ -41,19 +40,29 @@ class Player {
|
||||
// 初始化媒体会话
|
||||
this.initMediaSession();
|
||||
}
|
||||
/**
|
||||
* 洗牌数组(Fisher-Yates)
|
||||
*/
|
||||
private shuffleArray<T>(arr: T[]): T[] {
|
||||
const copy = arr.slice();
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
/**
|
||||
* 重置状态
|
||||
*/
|
||||
async resetStatus() {
|
||||
resetStatus() {
|
||||
const statusStore = useStatusStore();
|
||||
const musicStore = useMusicStore();
|
||||
// 停止播放
|
||||
this.player?.stop();
|
||||
// 重置状态
|
||||
statusStore.$patch({
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
progress: 0,
|
||||
chorus: 0,
|
||||
currentTimeOffset: 0,
|
||||
lyricIndex: -1,
|
||||
playStatus: false,
|
||||
@@ -63,16 +72,6 @@ class Player {
|
||||
playPlaylistId: 0,
|
||||
playSong: {},
|
||||
});
|
||||
// 延时防止 bug
|
||||
await sleep(50);
|
||||
}
|
||||
/**
|
||||
* 清理播放器
|
||||
*/
|
||||
private cleanPlayer() {
|
||||
Howler.unload();
|
||||
// this.player?.stop();
|
||||
// this.player?.unload();
|
||||
}
|
||||
/**
|
||||
* 获取当前播放歌曲
|
||||
@@ -125,12 +124,7 @@ class Player {
|
||||
// 歌词跨界处理
|
||||
const lyricIndex = index === -1 ? lyrics.length - 1 : index - 1;
|
||||
// 更新状态
|
||||
statusStore.$patch({
|
||||
currentTime,
|
||||
duration,
|
||||
progress,
|
||||
lyricIndex,
|
||||
});
|
||||
statusStore.$patch({ currentTime, duration, progress, lyricIndex });
|
||||
// 客户端事件
|
||||
if (isElectron) {
|
||||
// 歌词变化
|
||||
@@ -147,7 +141,7 @@ class Player {
|
||||
window.electron.ipcRenderer.send("set-bar", progress);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}, 250);
|
||||
}
|
||||
/**
|
||||
* 获取在线播放链接
|
||||
@@ -184,12 +178,22 @@ class Player {
|
||||
const keyWord = songData.name + "-" + artist;
|
||||
if (!songId || !keyWord) return null;
|
||||
// 尝试解锁
|
||||
const [neteaseUrl, kuwoUrl] = await Promise.all([
|
||||
const results = await Promise.allSettled([
|
||||
unlockSongUrl(songId, keyWord, "netease"),
|
||||
unlockSongUrl(songId, keyWord, "kuwo"),
|
||||
]);
|
||||
if (neteaseUrl.code === 200 && neteaseUrl.url !== "") return neteaseUrl.url;
|
||||
if (kuwoUrl.code === 200 && kuwoUrl.url !== "") return kuwoUrl.url;
|
||||
// 解析结果
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error in getUnlockSongUrl", error);
|
||||
@@ -200,8 +204,9 @@ class Player {
|
||||
* 创建播放器
|
||||
* @param src 播放地址
|
||||
* @param autoPlay 是否自动播放
|
||||
* @param seek 播放位置
|
||||
*/
|
||||
private createPlayer(src: string, autoPlay: boolean = true) {
|
||||
private async createPlayer(src: string, autoPlay: boolean = true, seek: number = 0) {
|
||||
// 获取数据
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
@@ -210,9 +215,7 @@ class Player {
|
||||
// 播放信息
|
||||
const { id, path, type } = musicStore.playSong;
|
||||
// 清理播放器
|
||||
this.cleanPlayer();
|
||||
// 禁用自动解锁
|
||||
Howler.autoUnlock = false;
|
||||
Howler.unload();
|
||||
// 创建播放器
|
||||
this.player = new Howl({
|
||||
src,
|
||||
@@ -225,14 +228,16 @@ class Player {
|
||||
rate: statusStore.playRate,
|
||||
});
|
||||
// 播放器事件
|
||||
this.playerEvent();
|
||||
this.playerEvent({ seek });
|
||||
// 播放设备
|
||||
if (!settingStore.showSpectrums) this.toggleOutputDevice();
|
||||
// 自动播放
|
||||
if (autoPlay) this.play();
|
||||
// 获取歌词数据 - 非电台和本地
|
||||
if (type !== "radio" && !path) this.getLyricData(id);
|
||||
else resetSongLyric();
|
||||
// 获取歌曲附加信息 - 非电台和本地
|
||||
if (type !== "radio" && !path) {
|
||||
this.getLyricData(id);
|
||||
this.getChorus(id);
|
||||
} else resetSongLyric();
|
||||
// 定时获取状态
|
||||
if (!this.playerInterval) this.handlePlayStatus();
|
||||
// 新增播放历史
|
||||
@@ -247,23 +252,28 @@ class Player {
|
||||
/**
|
||||
* 播放器事件
|
||||
*/
|
||||
private playerEvent() {
|
||||
private playerEvent(
|
||||
options: {
|
||||
// 恢复进度
|
||||
seek?: number;
|
||||
} = { seek: 0 },
|
||||
) {
|
||||
// 获取数据
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
const playSongData = this.getPlaySongData();
|
||||
// 获取配置
|
||||
const { seek } = options;
|
||||
// 初次加载
|
||||
this.player.once("load", () => {
|
||||
// 允许跨域
|
||||
const audioDom = this.getAudioDom();
|
||||
audioDom.crossOrigin = "anonymous";
|
||||
// 恢复进度( 需距离本曲结束大于 2 秒 )
|
||||
if (settingStore.memoryLastSeek && statusStore.duration - statusStore.currentTime > 2) {
|
||||
this.setSeek(statusStore.currentTime);
|
||||
} else {
|
||||
this.setSeek(0);
|
||||
if (settingStore.showSpectrums) {
|
||||
const audioDom = this.getAudioDom();
|
||||
audioDom.crossOrigin = "anonymous";
|
||||
}
|
||||
// 恢复进度( 需距离本曲结束大于 2 秒 )
|
||||
if (seek && statusStore.duration - statusStore.currentTime > 2) this.setSeek(seek);
|
||||
// 更新状态
|
||||
statusStore.playLoading = false;
|
||||
// ipc
|
||||
@@ -277,21 +287,19 @@ class Player {
|
||||
});
|
||||
// 播放
|
||||
this.player.on("play", () => {
|
||||
window.document.title = this.getPlayerInfo() || "SPlayer";
|
||||
// ipc
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.send("play-status-change", true);
|
||||
window.electron.ipcRenderer.send("play-song-change", this.getPlayerInfo());
|
||||
}
|
||||
// 更改标题
|
||||
if (!isElectron) window.document.title = this.getPlayerInfo() || "SPlayer";
|
||||
console.log("▶️ song play:", playSongData);
|
||||
});
|
||||
// 暂停
|
||||
this.player.on("pause", () => {
|
||||
if (!isElectron) window.document.title = "SPlayer";
|
||||
// ipc
|
||||
if (isElectron) window.electron.ipcRenderer.send("play-status-change", false);
|
||||
// 更改标题
|
||||
if (!isElectron) window.document.title = "SPlayer";
|
||||
console.log("⏸️ song pause:", playSongData);
|
||||
});
|
||||
// 结束
|
||||
@@ -415,36 +423,48 @@ class Player {
|
||||
const lyricRes = await songLyric(id);
|
||||
parsedLyricsData(lyricRes);
|
||||
}
|
||||
/**
|
||||
* 获取副歌时间
|
||||
* @param id 歌曲id
|
||||
*/
|
||||
private async getChorus(id: number) {
|
||||
const statusStore = useStatusStore();
|
||||
const result = await songChorus(id);
|
||||
if (result?.code !== 200 || result?.chorus?.length === 0) {
|
||||
statusStore.chorus = 0;
|
||||
return;
|
||||
}
|
||||
// 计算并保存
|
||||
const chorus = result?.chorus?.[0]?.startTime;
|
||||
const time = ((chorus / 1000 / statusStore.duration) * 100).toFixed(2);
|
||||
statusStore.chorus = Number(time);
|
||||
}
|
||||
/**
|
||||
* 播放错误
|
||||
* 在播放错误时,播放下一首
|
||||
*/
|
||||
private errorNext(errCode?: number) {
|
||||
private async errorNext(errCode?: number) {
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
// 错误 2 通常为网络地址过期
|
||||
if (errCode === 2) {
|
||||
// 替换播放链接
|
||||
}
|
||||
// 清除进度
|
||||
// this.resetStatus();
|
||||
// statusStore.playStatus = false;
|
||||
// ipc
|
||||
// if (isElectron) window.electron.ipcRenderer.send("play-status-change", false);
|
||||
// 次数加一
|
||||
this.testNumber++;
|
||||
if (this.testNumber > 5) {
|
||||
this.testNumber = 0;
|
||||
statusStore.playLoading = false;
|
||||
this.resetStatus();
|
||||
window.$message.error("当前重试次数过多,请稍后再试");
|
||||
return;
|
||||
}
|
||||
// 错误 2 通常为网络地址过期
|
||||
if (errCode === 2) {
|
||||
// 重载播放器
|
||||
await this.initPlayer(true, this.getSeek());
|
||||
return;
|
||||
}
|
||||
// 播放下一曲
|
||||
if (dataStore.playList.length > 1) {
|
||||
this.nextOrPrev("next");
|
||||
await this.nextOrPrev("next");
|
||||
} else {
|
||||
statusStore.playStatus = false;
|
||||
window.$message.error("当前列表暂无可播放歌曲");
|
||||
this.cleanPlayList();
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -511,8 +531,9 @@ class Player {
|
||||
* 初始化播放器
|
||||
* 核心外部调用
|
||||
* @param autoPlay 是否自动播放
|
||||
* @param seek 播放位置
|
||||
*/
|
||||
async initPlayer(autoPlay: boolean = true) {
|
||||
async initPlayer(autoPlay: boolean = true, seek: number = 0) {
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
@@ -522,15 +543,13 @@ class Player {
|
||||
const playSongData = this.getPlaySongData();
|
||||
if (!playSongData) return;
|
||||
const { id, dj, path, type } = playSongData;
|
||||
// 清理播放器
|
||||
// this.cleanPlayer();
|
||||
// 更改当前播放歌曲
|
||||
musicStore.playSong = playSongData;
|
||||
// 更改状态
|
||||
statusStore.playLoading = true;
|
||||
// 本地歌曲
|
||||
if (path) {
|
||||
this.createPlayer(path, autoPlay);
|
||||
await this.createPlayer(path, autoPlay, seek);
|
||||
// 获取歌曲元信息
|
||||
await this.parseLocalMusicInfo(path);
|
||||
}
|
||||
@@ -542,7 +561,7 @@ class Player {
|
||||
// 正常播放地址
|
||||
if (url) {
|
||||
statusStore.playUblock = false;
|
||||
this.createPlayer(url, autoPlay);
|
||||
await this.createPlayer(url, autoPlay, seek);
|
||||
}
|
||||
// 尝试解灰
|
||||
else if (isElectron && type !== "radio" && settingStore.useSongUnlock) {
|
||||
@@ -550,7 +569,7 @@ class Player {
|
||||
if (unlockUrl) {
|
||||
statusStore.playUblock = true;
|
||||
console.log("🎼 Song unlock successfully:", unlockUrl);
|
||||
this.createPlayer(unlockUrl, autoPlay);
|
||||
await this.createPlayer(unlockUrl, autoPlay, seek);
|
||||
} else {
|
||||
statusStore.playUblock = false;
|
||||
// 是否为最后一首
|
||||
@@ -564,8 +583,7 @@ class Player {
|
||||
}
|
||||
} else {
|
||||
if (dataStore.playList.length === 1) {
|
||||
// this.cleanPlayer();
|
||||
await this.resetStatus();
|
||||
this.resetStatus();
|
||||
window.$message.warning("当前播放列表已无可播放歌曲,请更换");
|
||||
return;
|
||||
} else {
|
||||
@@ -583,38 +601,56 @@ class Player {
|
||||
}
|
||||
/**
|
||||
* 播放
|
||||
* @param fade 是否淡入
|
||||
*/
|
||||
play(fade: boolean = true) {
|
||||
async play() {
|
||||
const statusStore = useStatusStore();
|
||||
// 已在播放
|
||||
if (this.player.playing()) {
|
||||
statusStore.playStatus = true;
|
||||
return;
|
||||
}
|
||||
this.player.play();
|
||||
statusStore.playStatus = true;
|
||||
this.player.once("play", () => {
|
||||
this.player.fade(0, statusStore.playVolume, fade ? this.getFadeTime() : 0);
|
||||
// 淡入
|
||||
await new Promise<void>((resolve) => {
|
||||
this.player.once("play", () => {
|
||||
// 在淡入开始时立即设置播放状态
|
||||
statusStore.playStatus = true;
|
||||
this.player.fade(0, statusStore.playVolume, this.getFadeTime());
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 暂停
|
||||
* @param fade 是否淡出
|
||||
* @param changeStatus 是否更改播放状态
|
||||
*/
|
||||
pause(fade: boolean = true) {
|
||||
async pause(changeStatus: boolean = true) {
|
||||
const statusStore = useStatusStore();
|
||||
this.player.fade(statusStore.playVolume, 0, fade ? this.getFadeTime() : 0);
|
||||
this.player.once("fade", () => {
|
||||
this.player.pause();
|
||||
statusStore.playStatus = false;
|
||||
|
||||
// 播放器未加载完成
|
||||
if (this.player.state() !== "loaded") {
|
||||
return;
|
||||
}
|
||||
|
||||
// 立即设置播放状态
|
||||
if (changeStatus) statusStore.playStatus = false;
|
||||
|
||||
// 淡出
|
||||
await new Promise<void>((resolve) => {
|
||||
this.player.fade(statusStore.playVolume, 0, this.getFadeTime());
|
||||
this.player.once("fade", () => {
|
||||
this.player.pause();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 播放或暂停
|
||||
*/
|
||||
playOrPause() {
|
||||
async playOrPause() {
|
||||
const statusStore = useStatusStore();
|
||||
statusStore.playStatus ? this.pause() : this.play();
|
||||
if (statusStore.playStatus) await this.pause();
|
||||
else await this.play();
|
||||
}
|
||||
/**
|
||||
* 下一首或上一首
|
||||
@@ -622,93 +658,125 @@ class Player {
|
||||
* @param play 是否立即播放
|
||||
*/
|
||||
async nextOrPrev(type: "next" | "prev" = "next", play: boolean = true) {
|
||||
const statusStore = useStatusStore();
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
// 获取数据
|
||||
const { playList } = dataStore;
|
||||
const { playSong } = musicStore;
|
||||
const { playSongMode, playHeartbeatMode } = statusStore;
|
||||
// 列表长度
|
||||
const playListLength = playList.length;
|
||||
// 播放列表是否为空
|
||||
if (playListLength === 0) throw new Error("The play list is empty");
|
||||
// 打卡
|
||||
this.scrobbleSong();
|
||||
// 若为私人FM
|
||||
if (statusStore.personalFmMode) {
|
||||
await this.initPersonalFM(true);
|
||||
return;
|
||||
}
|
||||
// 只有一首歌的特殊处理
|
||||
if (playListLength === 1) {
|
||||
this.setSeek(0);
|
||||
statusStore.lyricIndex = -1;
|
||||
const songData = playList[0];
|
||||
if (!songData) throw new Error("The song data is not found");
|
||||
musicStore.playSong = songData;
|
||||
try {
|
||||
const statusStore = useStatusStore();
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
// 获取数据
|
||||
const { playList } = dataStore;
|
||||
const { playSong } = musicStore;
|
||||
const { playSongMode, playHeartbeatMode } = statusStore;
|
||||
// 列表长度
|
||||
const playListLength = playList.length;
|
||||
// 播放列表是否为空
|
||||
if (playListLength === 0) throw new Error("Play list is empty");
|
||||
// 若为私人FM
|
||||
if (statusStore.personalFmMode) {
|
||||
await this.initPersonalFM(true);
|
||||
return;
|
||||
}
|
||||
// 只有一首歌的特殊处理
|
||||
if (playListLength === 1) {
|
||||
statusStore.lyricIndex = -1;
|
||||
this.setSeek(0);
|
||||
await this.play();
|
||||
}
|
||||
// 列表循环或处于心动模式或随机模式
|
||||
if (
|
||||
playSongMode === "repeat" ||
|
||||
playSongMode === "shuffle" ||
|
||||
playHeartbeatMode ||
|
||||
playSong.type === "radio"
|
||||
) {
|
||||
statusStore.playIndex += type === "next" ? 1 : -1;
|
||||
}
|
||||
// 单曲循环
|
||||
else if (playSongMode === "repeat-once") {
|
||||
statusStore.lyricIndex = -1;
|
||||
this.setSeek(0);
|
||||
await this.play();
|
||||
return;
|
||||
} else {
|
||||
throw new Error("The play mode is not supported");
|
||||
}
|
||||
// 索引是否越界
|
||||
if (statusStore.playIndex < 0) {
|
||||
statusStore.playIndex = playListLength - 1;
|
||||
} else if (statusStore.playIndex >= playListLength) {
|
||||
statusStore.playIndex = 0;
|
||||
}
|
||||
// 暂停
|
||||
await this.pause(false);
|
||||
// 初始化播放器
|
||||
await this.initPlayer(play);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error("Error in nextOrPrev:", error);
|
||||
throw error;
|
||||
}
|
||||
// 列表循环或处于心动模式
|
||||
if (playSongMode === "repeat" || playHeartbeatMode || playSong.type === "radio") {
|
||||
statusStore.playIndex += type === "next" ? 1 : -1;
|
||||
}
|
||||
// 随机播放
|
||||
else if (playSongMode === "shuffle") {
|
||||
let newIndex: number;
|
||||
// 确保不会随机到同一首
|
||||
do {
|
||||
newIndex = Math.floor(Math.random() * playListLength);
|
||||
} while (newIndex === statusStore.playIndex);
|
||||
statusStore.playIndex = newIndex;
|
||||
}
|
||||
// 单曲循环
|
||||
else if (playSongMode === "repeat-once") {
|
||||
statusStore.lyricIndex = -1;
|
||||
this.setSeek(0);
|
||||
this.play();
|
||||
return;
|
||||
} else {
|
||||
throw new Error("The play mode is not supported");
|
||||
}
|
||||
// 索引是否越界
|
||||
if (statusStore.playIndex < 0) {
|
||||
statusStore.playIndex = playListLength - 1;
|
||||
} else if (statusStore.playIndex >= playListLength) {
|
||||
statusStore.playIndex = 0;
|
||||
}
|
||||
// 停止播放
|
||||
await this.resetStatus();
|
||||
// 初始化播放器
|
||||
this.initPlayer(play);
|
||||
}
|
||||
/**
|
||||
* 切换播放模式
|
||||
* @param mode 播放模式 repeat repeat-once shuffle
|
||||
* @param mode 播放模式 repeat / repeat-once / shuffle
|
||||
*/
|
||||
togglePlayMode(mode: PlayModeType | false) {
|
||||
async togglePlayMode(mode: PlayModeType | false) {
|
||||
const statusStore = useStatusStore();
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
// 退出心动模式
|
||||
if (statusStore.playHeartbeatMode) this.toggleHeartMode(false);
|
||||
// 若传入了指定模式
|
||||
// 计算目标模式
|
||||
let targetMode: PlayModeType;
|
||||
if (mode) {
|
||||
statusStore.playSongMode = mode;
|
||||
targetMode = mode;
|
||||
} else {
|
||||
switch (statusStore.playSongMode) {
|
||||
case "repeat":
|
||||
statusStore.playSongMode = "repeat-once";
|
||||
targetMode = "repeat-once";
|
||||
break;
|
||||
case "shuffle":
|
||||
statusStore.playSongMode = "repeat";
|
||||
targetMode = "repeat";
|
||||
break;
|
||||
case "repeat-once":
|
||||
statusStore.playSongMode = "shuffle";
|
||||
targetMode = "shuffle";
|
||||
break;
|
||||
default:
|
||||
statusStore.playSongMode = "repeat";
|
||||
targetMode = "repeat";
|
||||
}
|
||||
}
|
||||
// 进入随机模式:保存原顺序并打乱当前歌单
|
||||
if (targetMode === "shuffle" && statusStore.playSongMode !== "shuffle") {
|
||||
const currentList = dataStore.playList;
|
||||
if (currentList && currentList.length > 1) {
|
||||
const currentSongId = musicStore.playSong?.id;
|
||||
await dataStore.setOriginalPlayList(currentList);
|
||||
const shuffled = this.shuffleArray(currentList);
|
||||
await dataStore.setPlayList(shuffled);
|
||||
if (currentSongId) {
|
||||
const newIndex = shuffled.findIndex((s: any) => s?.id === currentSongId);
|
||||
if (newIndex !== -1) useStatusStore().playIndex = newIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 离开随机模式:恢复到原顺序
|
||||
if (
|
||||
statusStore.playSongMode === "shuffle" &&
|
||||
(targetMode === "repeat" || targetMode === "repeat-once")
|
||||
) {
|
||||
const original = await dataStore.getOriginalPlayList();
|
||||
if (original && original.length) {
|
||||
const currentSongId = musicStore.playSong?.id;
|
||||
await dataStore.setPlayList(original);
|
||||
if (currentSongId) {
|
||||
const origIndex = original.findIndex((s: any) => s?.id === currentSongId);
|
||||
useStatusStore().playIndex = origIndex !== -1 ? origIndex : 0;
|
||||
} else {
|
||||
useStatusStore().playIndex = 0;
|
||||
}
|
||||
await dataStore.clearOriginalPlayList();
|
||||
}
|
||||
}
|
||||
// 应用模式
|
||||
statusStore.playSongMode = targetMode;
|
||||
this.playModeSyncIpc();
|
||||
}
|
||||
/**
|
||||
@@ -747,20 +815,28 @@ class Player {
|
||||
}
|
||||
/**
|
||||
* 设置播放音量
|
||||
* @param volume 音量
|
||||
* @param actions 音量
|
||||
*/
|
||||
setVolume(volume: number | "up" | "down") {
|
||||
setVolume(actions: number | "up" | "down" | WheelEvent) {
|
||||
const statusStore = useStatusStore();
|
||||
const increment = 0.05;
|
||||
// 直接设置
|
||||
if (typeof volume === "number") {
|
||||
volume = Math.max(0, Math.min(volume, 1));
|
||||
} else {
|
||||
const increment = 0.05;
|
||||
if (typeof actions === "number") {
|
||||
actions = Math.max(0, Math.min(actions, 1));
|
||||
}
|
||||
// 分类调节
|
||||
else if (actions === "up" || actions === "down") {
|
||||
statusStore.playVolume = Math.max(
|
||||
0,
|
||||
Math.min(statusStore.playVolume + (volume === "up" ? increment : -increment), 1),
|
||||
Math.min(statusStore.playVolume + (actions === "up" ? increment : -increment), 1),
|
||||
);
|
||||
}
|
||||
// 鼠标滚轮
|
||||
else {
|
||||
const deltaY = actions.deltaY;
|
||||
const volumeChange = deltaY > 0 ? -increment : increment;
|
||||
statusStore.playVolume = Math.max(0, Math.min(statusStore.playVolume + volumeChange, 1));
|
||||
}
|
||||
// 调整音量
|
||||
this.player.volume(statusStore.playVolume);
|
||||
}
|
||||
@@ -831,11 +907,19 @@ class Player {
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
// 获取配置
|
||||
const { showTip, scrobble, play } = options;
|
||||
// 打卡
|
||||
scrobble && this.scrobbleSong();
|
||||
const { showTip, play } = options;
|
||||
|
||||
// 处理随机播放模式
|
||||
let processedData = cloneDeep(data);
|
||||
if (statusStore.playSongMode === "shuffle") {
|
||||
// 保存原始播放列表
|
||||
await dataStore.setOriginalPlayList(cloneDeep(data));
|
||||
// 随机排序
|
||||
processedData = this.shuffleArray(processedData);
|
||||
}
|
||||
|
||||
// 更新列表
|
||||
await dataStore.setPlayList(cloneDeep(data));
|
||||
await dataStore.setPlayList(processedData);
|
||||
// 关闭特殊模式
|
||||
if (statusStore.playHeartbeatMode) this.toggleHeartMode(false);
|
||||
if (statusStore.personalFmMode) statusStore.personalFmMode = false;
|
||||
@@ -843,22 +927,20 @@ class Player {
|
||||
if (song && typeof song === "object" && "id" in song) {
|
||||
// 是否为当前播放歌曲
|
||||
if (musicStore.playSong.id === song.id) {
|
||||
if (play) this.play();
|
||||
if (play) await this.play();
|
||||
} else {
|
||||
// 查找索引
|
||||
const playIndex = data.findIndex((item) => item.id === song.id);
|
||||
statusStore.$patch({ playIndex, lyricIndex: -1 });
|
||||
// 清理并播放
|
||||
await this.resetStatus();
|
||||
this.initPlayer();
|
||||
// 查找索引(在处理后的列表中查找)
|
||||
statusStore.playIndex = processedData.findIndex((item) => item.id === song.id);
|
||||
// 播放
|
||||
await this.pause(false);
|
||||
await this.initPlayer();
|
||||
}
|
||||
} else {
|
||||
const playIndex =
|
||||
statusStore.playSongMode === "shuffle" ? Math.floor(Math.random() * data.length) : 0;
|
||||
statusStore.$patch({ playIndex, lyricIndex: -1 });
|
||||
// 清理并播放
|
||||
await this.resetStatus();
|
||||
this.initPlayer();
|
||||
statusStore.playIndex =
|
||||
statusStore.playSongMode === "shuffle" ? Math.floor(Math.random() * processedData.length) : 0;
|
||||
// 播放
|
||||
await this.pause(false);
|
||||
await this.initPlayer();
|
||||
}
|
||||
// 更改播放歌单
|
||||
musicStore.playPlaylistId = pid ?? 0;
|
||||
@@ -884,15 +966,16 @@ class Player {
|
||||
// 尝试添加
|
||||
const songIndex = await dataStore.setNextPlaySong(song, statusStore.playIndex);
|
||||
// 播放歌曲
|
||||
if (!songIndex) return;
|
||||
if (play) this.togglePlayIndex(songIndex);
|
||||
if (songIndex < 0) return;
|
||||
if (play) this.togglePlayIndex(songIndex, true);
|
||||
else window.$message.success("已添加至下一首播放");
|
||||
}
|
||||
/**
|
||||
* 切换播放索引
|
||||
* @param index 播放索引
|
||||
* @param play 是否立即播放
|
||||
*/
|
||||
togglePlayIndex(index: number) {
|
||||
async togglePlayIndex(index: number, play: boolean = false) {
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
// 获取数据
|
||||
@@ -900,14 +983,15 @@ class Player {
|
||||
// 若超出播放列表
|
||||
if (index >= playList.length) return;
|
||||
// 相同
|
||||
if (statusStore.playIndex === index) {
|
||||
if (!play && statusStore.playIndex === index) {
|
||||
this.play();
|
||||
return;
|
||||
}
|
||||
// 更改状态
|
||||
this.setSeek(0);
|
||||
statusStore.playIndex = index;
|
||||
this.initPlayer();
|
||||
// 清理并播放
|
||||
this.resetStatus();
|
||||
await this.initPlayer();
|
||||
}
|
||||
/**
|
||||
* 移除指定歌曲
|
||||
@@ -925,6 +1009,8 @@ class Player {
|
||||
this.cleanPlayList();
|
||||
return;
|
||||
}
|
||||
// 是否为当前播放歌曲
|
||||
const isCurrentPlay = statusStore.playIndex === index;
|
||||
// 深拷贝,防止影响原数据
|
||||
const newPlaylist = cloneDeep(playList);
|
||||
// 若将移除最后一首
|
||||
@@ -939,7 +1025,7 @@ class Player {
|
||||
newPlaylist.splice(index, 1);
|
||||
dataStore.setPlayList(newPlaylist);
|
||||
// 若为当前播放
|
||||
if (statusStore.playIndex === index) {
|
||||
if (isCurrentPlay) {
|
||||
this.initPlayer(statusStore.playStatus);
|
||||
}
|
||||
}
|
||||
@@ -951,14 +1037,15 @@ class Player {
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
// 停止播放
|
||||
await this.resetStatus();
|
||||
this.cleanPlayer();
|
||||
Howler.unload();
|
||||
// 清空数据
|
||||
this.resetStatus();
|
||||
statusStore.$patch({
|
||||
playListShow: false,
|
||||
showFullPlayer: false,
|
||||
playHeartbeatMode: false,
|
||||
personalFmMode: false,
|
||||
playIndex: -1,
|
||||
});
|
||||
musicStore.resetMusicData();
|
||||
dataStore.setPlayList([]);
|
||||
@@ -1038,8 +1125,12 @@ class Player {
|
||||
window.$message.success("已退出心动模式");
|
||||
return;
|
||||
}
|
||||
if (!isLogin()) {
|
||||
openUserLogin(true);
|
||||
if (isLogin() !== 1) {
|
||||
if (isLogin() === 0) {
|
||||
openUserLogin(true);
|
||||
} else {
|
||||
window.$message.warning("该登录模式暂不支持该操作");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (statusStore.playHeartbeatMode) {
|
||||
@@ -1080,27 +1171,6 @@ class Player {
|
||||
this.message?.destroy();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 听歌打卡
|
||||
*/
|
||||
async scrobbleSong() {
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
try {
|
||||
if (!isLogin()) return;
|
||||
// 获取所需数据
|
||||
const playSongData = this.getPlaySongData();
|
||||
if (!playSongData) return;
|
||||
const { id, name } = playSongData;
|
||||
const sourceid = musicStore.playPlaylistId;
|
||||
const time = statusStore.duration;
|
||||
// 网易云打卡
|
||||
console.log("打卡:", id, name, sourceid, time);
|
||||
await scrobble(id, sourceid, time);
|
||||
} catch (error) {
|
||||
console.error("Failed to scrobble song:", error);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 初始化私人FM
|
||||
* @param playNext 是否播放下一首
|
||||
@@ -1129,8 +1199,8 @@ class Player {
|
||||
await getPersonalFmData();
|
||||
}
|
||||
// 清理并播放
|
||||
await this.resetStatus();
|
||||
this.initPlayer();
|
||||
this.resetStatus();
|
||||
await this.initPlayer();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize personal FM:", error);
|
||||
|
||||
@@ -24,7 +24,8 @@ server.interceptors.request.use(
|
||||
if (!request.params) request.params = {};
|
||||
// Cookie
|
||||
if (!request.params.noCookie && (isLogin() || getCookie("MUSIC_U") !== null)) {
|
||||
request.params.cookie = `MUSIC_U=${getCookie("MUSIC_U")};`;
|
||||
const cookie = `MUSIC_U=${getCookie("MUSIC_U")};`;
|
||||
request.params.cookie = encodeURIComponent(cookie);
|
||||
}
|
||||
// realIP
|
||||
if (!isElectron && !request.url?.includes("/login")) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user