Compare commits

...

8 Commits

Author SHA1 Message Date
imsyy
a697799f48 Merge branch 'dev' of github.com:imsyy/SPlayer into dev 2024-10-09 17:54:10 +08:00
imsyy
7c59cc2ccb feat: 切换虚拟列表组件 2024-10-09 17:53:44 +08:00
底层用户
11acbcf7eb Merge pull request #269 from jcfun/dev
fix🐛: 修复了linux6.1内核下硬件加速被错误关闭的bug
2024-10-06 23:48:35 +08:00
imsyy
c35ede2158 🔧 build: 更新依赖 2024-10-05 11:29:33 +08:00
imsyy
dc480459eb 🐞 fix: 同步上游接口以解决登录风控问题 #270 2024-10-05 11:13:25 +08:00
jcfun
c100bfed8d fix🐛: 修复了linux6.1内核下硬件加速被错误关闭的bug 2024-10-03 12:28:23 +08:00
imsyy
2b48713565 feat: 搜索栏跳转完善 #262 2024-09-29 11:52:24 +08:00
imsyy
528f9b0aa6 🐞 fix: 修复快捷键错误 #264 2024-09-29 10:55:21 +08:00
55 changed files with 2454 additions and 1628 deletions

View File

@@ -1,6 +0,0 @@
node_modules
dist
out
.gitignore
auto-imports.d.ts
components.d.ts

View File

@@ -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,

View File

@@ -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",
},
};

304
auto-eslint.mjs Normal file
View File

@@ -0,0 +1,304 @@
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,
"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,
"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,
"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,
"usePrevious": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useRoute": true,
"useRouter": 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
}
}

7
auto-imports.d.ts vendored
View File

@@ -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']
@@ -65,6 +66,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']
@@ -174,6 +176,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 +195,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']
@@ -240,6 +244,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 +296,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')
}

7
components.d.ts vendored
View File

@@ -23,8 +23,8 @@ declare module 'vue' {
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']
LoginPhone: typeof import('./src/components/Modal/LoginPhone.vue')['default']
LoginQRCode: typeof import('./src/components/Modal/LoginQRCode.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']
@@ -124,11 +124,14 @@ 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']
SongListCard: typeof import('./src/components/Card/SongListCard.vue')['default']
SongListMenu: typeof import('./src/components/Menu/SongListMenu.vue')['default']
SongListNew: typeof import('./src/components/List/SongListNew.vue')['default']
SvgIcon: typeof import('./src/components/Global/SvgIcon.vue')['default']
TextContainer: typeof import('./src/components/Global/TextContainer.vue')['default']
UpdateApp: typeof import('./src/components/Modal/UpdateApp.vue')['default']

View File

@@ -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,
// 代理

View File

@@ -1,7 +1,7 @@
import { app, shell, BrowserWindow, BrowserWindowConstructorOptions } from "electron";
import { electronApp, optimizer } 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 { initTray, MainTray } from "./tray";
@@ -38,7 +38,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");

View File

@@ -142,8 +142,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();
});
// 是否阻止系统息屏

69
eslint.config.mjs Normal file
View 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",
},
},
];

View File

@@ -1,7 +1,7 @@
{
"name": "splayer",
"productName": "SPlayer",
"version": "3.0.0-alpha.2",
"version": "3.0.0-alpha.3",
"description": "A minimalist music player",
"main": "./out/main/index.js",
"author": "imsyy",
@@ -34,7 +34,7 @@
},
"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",
@@ -48,13 +48,13 @@
"@pixi/filter-color-matrix": "^7.4.2",
"@pixi/sprite": "^7.4.2",
"@vueuse/core": "^10.11.1",
"NeteaseCloudMusicApi": "^4.22.0",
"NeteaseCloudMusicApi": "^4.23.1",
"axios": "^1.7.7",
"change-case": "^5.4.4",
"dayjs": "^1.11.13",
"electron-dl": "^3.5.2",
"electron-store": "^8.2.0",
"electron-updater": "^6.3.4",
"electron-updater": "^6.3.9",
"file-saver": "^2.0.5",
"font-list": "^1.5.1",
"github-markdown-css": "^5.7.0",
@@ -66,10 +66,10 @@
"lodash-es": "^4.17.21",
"marked": "^14.1.2",
"music-metadata": "7.14.0",
"pinia": "^2.2.2",
"pinia": "^2.2.4",
"pinia-plugin-persistedstate": "^3.2.3",
"plyr": "^3.7.8",
"vue-virtual-scroller": "2.0.0-beta.8"
"vue-virt-list": "^1.5.2"
},
"devDependencies": {
"@electron-toolkit/preload": "^3.0.1",
@@ -80,35 +80,35 @@
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"@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/node": "^22.7.4",
"@typescript-eslint/eslint-plugin": "^8.8.0",
"@typescript-eslint/parser": "^8.8.0",
"@vitejs/plugin-vue": "^5.1.4",
"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": "^9.12.0",
"eslint-plugin-vue": "^9.28.0",
"fast-glob": "^3.3.2",
"fastify": "^4.28.1",
"naive-ui": "^2.39.0",
"naive-ui": "^2.40.1",
"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",
"sass": "^1.79.4",
"terser": "^5.34.1",
"typescript": "^5.6.2",
"unplugin-auto-import": "^0.18.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.3",
"vite": "^5.4.8",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-wasm": "^3.3.0",
"vue": "3.4.38",
"vue-router": "^4.4.3",
"vue": "3.5.10",
"vue-router": "^4.4.5",
"vue-tsc": "^2.1.6"
}
}

1705
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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(),
},
});
};

View File

@@ -0,0 +1,430 @@
<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.once="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" 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:
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);
}
&: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;
}
.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>

View File

@@ -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 = {};

View File

@@ -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);
.svg-container {
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-hex);
}
}
</style>

View File

@@ -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 = () => {

View File

@@ -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"
once
/>
<img
v-else
src="/images/video.jpg?assest"
class="loading-img"
alt="loading-img"
/>
</div>
</template>
</n-image>
<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 {

View File

@@ -1,23 +1,33 @@
<!-- 歌曲列表 - 虚拟列表 -->
<!-- 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"
<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 #before>
<slot name="header" />
<div class="song-item header" :style="{ margin }">
<!-- 悬浮顶栏 -->
<template #stickyHeader>
<div class="list-header song-card">
<n-text class="num">#</n-text>
<n-dropdown
v-if="!disabledSort"
@@ -42,183 +52,23 @@
<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)
<!-- 主内容 -->
<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)
"
>
<!-- 序号 -->
<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>
</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>
</div>
</DynamicScrollerItem>
</template>
<template #after>
<div class="list-after">
<!-- 加载更多 -->
<template #footer>
<div class="load-more">
<n-flex v-if="loadMore && loading">
<n-spin size="small" />
<n-text>{{ loadingText || "努力加载中" }}</n-text>
@@ -226,96 +76,105 @@
<n-divider v-else dashed> 没有更多啦 ~ </n-divider>
</div>
</template>
</DynamicScroller>
</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 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 {
const props = withDefaults(
defineProps<{
// 列表数据
data: SongType[];
// 列表类型
type?: "song" | "radio";
loadMore?: boolean;
// 列表高度
height?: number | "auto"; // px
// 是否加载
loading?: boolean;
// 加载更多
loadMore?: boolean;
loadingText?: string;
// 隐藏元素
hiddenAlbum?: boolean;
hiddenCover?: boolean;
hiddenPadding?: boolean;
hiddenSize?: boolean;
margin?: string;
height?: number;
// 隐藏滚动条
hiddenScrollbar?: boolean;
// 禁用排序
disabledSort?: boolean;
// 播放歌单 ID
playListId?: number;
// 禁用虚拟列表
disableVirtualList?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
}>(),
{
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,17 +211,14 @@ 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 }]) => ({
@@ -373,6 +229,18 @@ const sortMenuOptions = computed<DropdownOption[]>(() =>
})),
);
// 列表滚动
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 +252,53 @@ 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%;
.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;
padding: 8px 18px 8px 12px;
margin-right: 4px;
border: 1px solid transparent;
background-color: var(--background-hex);
.n-text {
opacity: 0.6;
}
.num {
position: relative;
display: flex;
@@ -497,112 +308,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;
}
}
.sort {
margin-left: 6px;
&::after {
@@ -612,58 +325,6 @@ onActivated(() => {
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;
}
}
&.play {
border-color: rgba(var(--primary), 0.58);
background-color: rgba(var(--primary), 0.28);
}
&.header {
border: none;
background-color: transparent;
.n-text {
opacity: 0.6;
}
.title {
position: relative;
padding: 0 20px 0 0;
&.has-sort {
&::after {
content: "";
@@ -684,41 +345,66 @@ onActivated(() => {
}
}
}
.album {
flex: 1;
padding-right: 20px;
}
.actions {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
}
.meta {
width: 50px;
font-size: 13px;
text-align: center;
&.size {
width: 60px;
}
&.date {
width: 80px;
}
}
: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);
// 滚动条
.virt-list__client {
transition:
height 0.3s,
width 0.3s,
opacity 0.3s;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
width: 6px;
background-color: transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(var(--primary), 0.28);
border-radius: 12px;
}
}
&.play {
.num {
.play {
&.hidden-scrollbar {
.list-header {
padding: 8px 12px;
}
.song-card {
padding-right: 0;
}
.virt-list__client {
&::-webkit-scrollbar {
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 +414,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 +437,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>

View File

@@ -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 player from "@/utils/player";
import { deleteSongs } from "@/utils/auth";
import { songUrl } from "@/api/song";
import player from "@/utils/player";
const emit = defineEmits<{ removeSong: [index: number[]] }>();
@@ -165,6 +166,15 @@ const openDropdown = (
key: "line-two",
type: "divider",
},
{
key: "cloud-import",
label: "导入至云盘",
show: !isCloud && type === "song" && !isLocal,
props: {
onClick: () => importSongToCloud(song),
},
icon: renderIcon("Cloud"),
},
{
key: "delete",
label: "从歌单中删除",
@@ -290,6 +300,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>

View File

@@ -120,6 +120,7 @@ const checkQrStatus = async () => {
window.$message.error("登录出错,请重试");
getQrData();
}
break;
default:
break;
}

View File

@@ -39,7 +39,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);
});
// 更新错误
window.electron.ipcRenderer.on("update-error", (_, error) => {

View File

@@ -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

View File

@@ -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" />
<!-- 主内容 -->
@@ -121,6 +132,15 @@ const playerContentKey = computed(() => {
// 是否显示评论
const isShowComment = computed(() => !musicStore.playSong.path && statusStore.showPlayerComment);
// 当前实时歌词
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: content?.tran };
});
// 播放器主色
const mainColor = computed(() => {
const mainColor = statusStore.songCoverTheme?.main;
@@ -222,6 +242,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;

View File

@@ -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,
}"
>
@@ -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();
@@ -364,7 +364,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;

View File

@@ -598,9 +598,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;

View File

@@ -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">
@@ -109,7 +113,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";

View File

@@ -38,8 +38,11 @@
<script setup lang="ts">
import { useStatusStore, useDataStore } from "@/stores";
import SearchInpMenu from "@/components/Menu/SearchInpMenu.vue";
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();
@@ -90,7 +93,7 @@ const updatePlaceholder = async () => {
};
// 前往搜索
const toSearch = (key: any, type: string = "keyword") => {
const toSearch = async (key: any, type: string = "keyword") => {
// 未输入内容且不存在推荐
if (!key && searchPlaceholder.value === "搜索音乐 / 视频") return;
if (!key && searchPlaceholder.value !== "搜索音乐 / 视频" && searchRealkeyword.value) {
@@ -110,8 +113,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 +126,10 @@ const toSearch = (key: any, type: string = "keyword") => {
});
break;
case "artists":
router.push({
name: "artist",
query: { id: key?.id },
});
break;
case "albums":
router.push({

View File

@@ -73,6 +73,13 @@
</div>
<n-switch v-model:value="settingStore.useSongUnlock" class="set" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">听歌打卡</n-text>
<n-text class="tip" :depth="3">是否将播放歌曲同步至网易云音乐</n-text>
</div>
<n-switch v-model:value="settingStore.scrobbleSong" class="set" :round="false" />
</n-card>
<n-card v-if="isElectron" class="set-item">
<div class="label">
<n-text class="name">音频输出设备</n-text>

View File

@@ -0,0 +1,66 @@
<!-- 图片组件 -->
<template>
<div ref="imgRef" class="s-image">
<Transition name="fade" mode="out-in">
<img :key="imgSrc" :src="imgSrc" :alt="alt || 'image'" />
</Transition>
<img v-if="!isLoaded" class="loading" :src="src" @load="imageLoaded" />
</div>
</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];
// 可视状态变化
"update:show": [show: boolean];
}>();
// 图片数据
const imgRef = ref<HTMLImageElement>();
const imgSrc = ref<string>(props.defaultSrc);
// 是否加载完成
const isLoaded = ref<boolean>(false);
// 是否可视
const isCanLook = useElementVisibility(imgRef);
// 图片加载完成
const imageLoaded = (e: Event) => {
isLoaded.value = true;
// 加载完成
emit("load", e);
};
// 可视状态变化
watchOnce(isCanLook, (show) => {
if (show) imgSrc.value = props.src || props.defaultSrc;
emit("update:show", show);
});
</script>
<style lang="scss" scoped>
.s-image {
position: relative;
img {
width: 100%;
height: 100%;
}
.loading {
position: absolute;
opacity: 0;
}
}
</style>

3
src/env.d.ts vendored
View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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" };
// }
// },
});
// 前置守卫

View File

@@ -141,6 +141,7 @@ const routes: Array<RouteRecordRaw> = [
{
path: "/radio",
name: "radio",
beforeEnter: (to, _, next) => {
if (!to.query.id) next({ path: "/403" });
else next();

View File

@@ -81,6 +81,7 @@ interface SettingState {
useRealIP: boolean;
realIP: string;
fullPlayerCache: boolean;
scrobbleSong: boolean;
}
export const useSettingStore = defineStore({
@@ -123,6 +124,7 @@ export const useSettingStore = defineStore({
smtcOpen: true, // 是否开启 SMTC
smtcOutputHighQualityCover: false, // 是否输出高清封面
playSongDemo: false, // 是否播放试听歌曲
scrobbleSong: false, // 是否打卡
// 歌词
lyricFontSize: 46, // 歌词大小
lyricTranFontSize: 22, // 歌词翻译大小
@@ -161,11 +163,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;
}

3
src/types/main.d.ts vendored
View File

@@ -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;

View File

@@ -16,11 +16,13 @@ import { formatCoverList, formatArtistsList, formatSongsList } from "@/utils/for
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");
@@ -195,6 +197,75 @@ 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("请登录后使用");
openUserLogin();
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("请登录后使用");
openUserLogin();
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("请登录后使用");
openUserLogin();
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 +350,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 || "删除歌曲失败,请重试");

View File

@@ -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();
@@ -80,10 +84,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 +105,11 @@ const keyDownEvent = debounce((event: KeyboardEvent) => {
}
}, 100);
// 版本输出
const printVersion = async () => {
log.success(`🚀 ${packageJson.version}`, packageJson.productName);
log.info(`👤 ${packageJson.author}`, packageJson.github);
};
export default init;

View File

@@ -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
View 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();

View File

@@ -72,7 +72,7 @@ export const sortOptions = {
timeDown: { name: "时长降序", show: "all", icon: renderIcon("SortClockDown") },
dateUp: { name: "日期升序", show: "radio", icon: renderIcon("SortDateUp") },
dateDown: { name: "日期降序", show: "radio", icon: renderIcon("SortDateDown") },
};
} as const;
// 自定义图片工具栏
export const renderToolbar = ({ nodes }: ImageRenderToolbarProps) => {

View File

@@ -176,7 +176,7 @@ export const openUpdatePlaylist = (id: number, data: CoverType, func: () => Prom
onSuccess: () => {
modal.destroy();
// 触发回调
isFunction(func) && func();
if (isFunction(func)) func();
},
});
},

View File

@@ -69,10 +69,12 @@ class Player {
/**
* 清理播放器
*/
private cleanPlayer() {
private async cleanPlayer() {
Howler.unload();
// this.player?.stop();
// this.player?.unload();
// 延时防止 bug
await sleep(50);
}
/**
* 获取当前播放歌曲
@@ -201,7 +203,7 @@ class Player {
* @param src 播放地址
* @param autoPlay 是否自动播放
*/
private createPlayer(src: string, autoPlay: boolean = true) {
private async createPlayer(src: string, autoPlay: boolean = true) {
// 获取数据
const dataStore = useDataStore();
const musicStore = useMusicStore();
@@ -210,7 +212,7 @@ class Player {
// 播放信息
const { id, path, type } = musicStore.playSong;
// 清理播放器
this.cleanPlayer();
await this.cleanPlayer();
// 禁用自动解锁
Howler.autoUnlock = false;
// 创建播放器
@@ -530,7 +532,7 @@ class Player {
statusStore.playLoading = true;
// 本地歌曲
if (path) {
this.createPlayer(path, autoPlay);
await this.createPlayer(path, autoPlay);
// 获取歌曲元信息
await this.parseLocalMusicInfo(path);
}
@@ -542,7 +544,7 @@ class Player {
// 正常播放地址
if (url) {
statusStore.playUblock = false;
this.createPlayer(url, autoPlay);
await this.createPlayer(url, autoPlay);
}
// 尝试解灰
else if (isElectron && type !== "radio" && settingStore.useSongUnlock) {
@@ -550,7 +552,7 @@ class Player {
if (unlockUrl) {
statusStore.playUblock = true;
console.log("🎼 Song unlock successfully:", unlockUrl);
this.createPlayer(unlockUrl, autoPlay);
await this.createPlayer(unlockUrl, autoPlay);
} else {
statusStore.playUblock = false;
// 是否为最后一首
@@ -614,7 +616,8 @@ class Player {
*/
playOrPause() {
const statusStore = useStatusStore();
statusStore.playStatus ? this.pause() : this.play();
if (statusStore.playStatus) this.pause();
else this.play();
}
/**
* 下一首或上一首
@@ -833,7 +836,7 @@ class Player {
// 获取配置
const { showTip, scrobble, play } = options;
// 打卡
scrobble && this.scrobbleSong();
if (scrobble) this.scrobbleSong();
// 更新列表
await dataStore.setPlayList(cloneDeep(data));
// 关闭特殊模式
@@ -850,7 +853,7 @@ class Player {
statusStore.$patch({ playIndex, lyricIndex: -1 });
// 清理并播放
await this.resetStatus();
this.initPlayer();
await this.initPlayer();
}
} else {
const playIndex =
@@ -858,7 +861,7 @@ class Player {
statusStore.$patch({ playIndex, lyricIndex: -1 });
// 清理并播放
await this.resetStatus();
this.initPlayer();
await this.initPlayer();
}
// 更改播放歌单
musicStore.playPlaylistId = pid ?? 0;
@@ -952,7 +955,7 @@ class Player {
const statusStore = useStatusStore();
// 停止播放
await this.resetStatus();
this.cleanPlayer();
await this.cleanPlayer();
// 清空数据
statusStore.$patch({
playListShow: false,
@@ -1086,8 +1089,10 @@ class Player {
async scrobbleSong() {
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
try {
if (!isLogin()) return;
if (!settingStore.scrobbleSong) return;
// 获取所需数据
const playSongData = this.getPlaySongData();
if (!playSongData) return;

View File

@@ -26,7 +26,7 @@
/>
</div>
<div class="data">
<div class="name">
<div class="name text-hidden">
<n-text class="name-text">{{ artistDetailData.name || "未知艺术家" }}</n-text>
<n-text v-if="artistDetailData?.alia" class="name-alias" depth="3">
{{ artistDetailData.alia || "未知艺术家" }}
@@ -89,7 +89,13 @@
</template>
播放
</n-button>
<n-button :focusable="false" strong secondary round>
<n-button
:focusable="false"
strong
secondary
round
@click="toLikeArtist(artistId, !isLikeArtist)"
>
<template #icon>
<SvgIcon :name="isLikeArtist ? 'Favorite' : 'FavoriteBorder'" />
</template>
@@ -145,6 +151,7 @@ import { renderToolbar } from "@/utils/meta";
import { artistDetail } from "@/api/artist";
import { formatArtistsList } from "@/utils/format";
import { useDataStore, useSettingStore } from "@/stores";
import { toLikeArtist } from "@/utils/auth";
import ArtistSongs from "./songs.vue";
const router = useRouter();

View File

@@ -74,7 +74,7 @@
</n-flex>
<!-- 列表 -->
<Transition name="fade" mode="out-in">
<SongList v-if="!searchValue || searchData?.length" :data="listData" :loading="loading" />
<SongList v-if="!searchValue || searchData?.length" :data="listDataShow" :loading="loading" />
<n-empty
v-else
:description="`搜不到关于 ${searchValue} 的任何歌曲呀`"
@@ -116,7 +116,7 @@ const searchValue = ref<string>("");
const searchData = ref<SongType[]>([]);
// 列表歌曲
const listData = computed<SongType[]>(() => {
const listDataShow = computed<SongType[]>(() => {
if (searchValue.value && searchData.value.length) return searchData.value;
return cloudData.value;
});

View File

@@ -37,7 +37,7 @@
</n-flex>
</div>
<!-- 列表 -->
<SongList :data="musicStore.dailySongsData.list" :loading="true" disableVirtualList />
<SongList :data="musicStore.dailySongsData.list" :loading="true" height="auto" />
</div>
</template>
@@ -108,5 +108,8 @@ onMounted(updateDailySongsData);
margin-top: 30px;
}
}
.song-list {
height: auto;
}
}
</style>

View File

@@ -139,7 +139,6 @@ onMounted(getAllNewData);
margin-top: 20px;
}
.song-list {
margin-top: 20px;
flex: 1;
}
}

View File

@@ -16,7 +16,7 @@
<template #info>
<div
v-for="(song, songIndex) in item.tracks"
:key="index"
:key="songIndex"
class="song-item text-hidden"
>
<n-text class="name">{{ songIndex + 1 }}. {{ song.first }}</n-text>

View File

@@ -39,7 +39,6 @@
v-if="dataStore.historyList.length > 0"
:data="dataStore.historyList"
:loading="true"
hiddenPadding
hiddenCover
hiddenSize
/>
@@ -103,7 +102,7 @@ const cleanHistory = () => {
}
.menu {
width: 100%;
margin-bottom: 20px;
margin-bottom: 12px;
.n-button {
height: 40px;
transition: all 0.3s var(--n-bezier);

View File

@@ -36,11 +36,6 @@
<n-text>{{ item.name }}</n-text>
<SvgIcon v-if="item.path" :size="26" name="Right" />
</n-h3>
<!-- <n-button :focusable="false" quaternary circle>
<template #icon>
<SvgIcon name="Refresh" />
</template>
</n-button> -->
</n-flex>
<!-- 列表 -->
<ArtistList v-if="item.type === 'artist'" :data="item.list" :loading="true" />
@@ -88,7 +83,7 @@ const dailySongsTitle = computed(() => {
const day = new Date().getDate();
return h("div", { class: "date" }, [
h("div", { class: "date-icon" }, [
h(SvgIcon, { name: "Calendar-Empty", size: 30 }),
h(SvgIcon, { name: "Calendar-Empty", size: 30, depth: 2 }),
h(NText, null, () => day),
]),
h(NText, { class: "name" }, () => ["每日推荐"]),

View File

@@ -141,7 +141,6 @@
:data="albumDataShow"
:loading="loading"
:height="songListHeight"
hidden-padding
hidden-album
@scroll="listScroll"
/>

View File

@@ -149,7 +149,6 @@
:loading="loading"
:height="songListHeight"
:playListId="playlistId"
hidden-padding
@scroll="listScroll"
@removeSong="removeSong"
/>

View File

@@ -138,7 +138,14 @@
</template>
编辑歌单
</n-button>
<n-button v-else :focusable="false" strong secondary round>
<n-button
v-else
:focusable="false"
strong
secondary
round
@click="toLikePlaylist(playlistId, !isLikePlaylist)"
>
<template #icon>
<SvgIcon :name="isLikePlaylist ? 'Favorite' : 'FavoriteBorder'" />
</template>
@@ -187,7 +194,6 @@
:loading="loading"
:height="songListHeight"
:playListId="playlistId"
hidden-padding
@scroll="listScroll"
@removeSong="removeSong"
/>
@@ -213,7 +219,7 @@ import { playlistDetail, playlistAllSongs, deletePlaylist } from "@/api/playlist
import { formatCoverList, formatSongsList } from "@/utils/format";
import { coverLoaded, formatNumber, fuzzySearch, renderIcon } from "@/utils/helper";
import { renderToolbar } from "@/utils/meta";
import { isLogin, updateUserLikePlaylist } from "@/utils/auth";
import { isLogin, toLikePlaylist, updateUserLikePlaylist } from "@/utils/auth";
import { debounce } from "lodash-es";
import { useDataStore, useStatusStore } from "@/stores";
import { openBatchList, openUpdatePlaylist } from "@/utils/modal";

View File

@@ -101,11 +101,17 @@
: "播放"
}}
</n-button>
<n-button :focusable="false" strong secondary round>
<n-button
:focusable="false"
strong
secondary
round
@click="toSubRadio(radioId, !isLikeRadio)"
>
<template #icon>
<SvgIcon :name="isLikePlaylist ? 'Favorite' : 'FavoriteBorder'" />
<SvgIcon :name="isLikeRadio ? 'Favorite' : 'FavoriteBorder'" />
</template>
{{ isLikePlaylist ? "取消收藏" : "收藏播客" }}
{{ isLikeRadio ? "取消订阅" : "订阅播客" }}
</n-button>
<!-- 更多 -->
<n-dropdown :options="moreOptions" trigger="click" placement="bottom-start">
@@ -151,7 +157,6 @@
:height="songListHeight"
:radioId="radioId"
type="radio"
hidden-padding
@scroll="listScroll"
/>
<n-empty
@@ -179,6 +184,7 @@ import { useDataStore, useStatusStore } from "@/stores";
import { radioAllProgram, radioDetail } from "@/api/radio";
import player from "@/utils/player";
import { formatTimestamp } from "@/utils/time";
import { toSubRadio } from "@/utils/auth";
const router = useRouter();
const dataStore = useDataStore();
@@ -214,14 +220,12 @@ const songListHeight = computed(() => {
});
// 是否处于收藏播客
const isLikePlaylist = computed(() => {
return dataStore.userLikeData.playlists.some(
(playlist) => playlist.id === radioDetailData.value?.id,
);
const isLikeRadio = computed(() => {
return dataStore.userLikeData.djs.some((radio) => radio.id === radioDetailData.value?.id);
});
// 是否处于播客页面
const isPlaylistPage = computed<boolean>(() => router.currentRoute.value.name === "playlist");
const isPlaylistPage = computed<boolean>(() => router.currentRoute.value.name === "radio");
// 是否为相同播客
const isSamePlaylist = computed<boolean>(() => oldRadioId.value === radioId.value);

View File

@@ -20,7 +20,7 @@
:key="chooseArtist"
:data="chooseArtist ? artistData[chooseArtist] : []"
:loading="true"
hidden-cover
:hidden-cover="!settingStore.showLocalCover"
/>
</Transition>
</div>