Compare commits

...

13 Commits

Author SHA1 Message Date
sqj
f02264c80c 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-27 00:14:45 +08:00
sqj
d0d5f918bd 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:48:21 +08:00
sqj
761d265d18 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:27:50 +08:00
sqj
204df64535 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:25:22 +08:00
sqj
cc814eddbd 1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
2025-09-26 23:08:18 +08:00
sqj
51df14a9e9 1. 歌单
- 新增右键移除歌曲
   - local 页歌单右键操作
   - 歌单页支持修改封面
2. debug:右键菜单二级菜单位置决策
2025-09-25 19:56:45 +08:00
sqj
2473b36928 feat:列表新增右键菜单;fix:播放列表滚动条,搜索页切换源重新加载 2025-09-25 02:43:02 +08:00
sqj
dbba7a3d26 1. 软件启动位置 宽高记忆 限制软件最大宽高
2. debug: 修复歌曲音质支持短缺问题
2025-09-22 19:36:07 +08:00
sqj
a817865bd8 Merge branch 'main' of https://github.com/timeshiftsauce/CeruMusic 2025-09-22 19:34:58 +08:00
sqj
c4a4d26bd8 1. 软件启动位置 宽高记忆 限制软件最大宽高
2. debug: 修复歌曲音质支持短缺问题
2025-09-22 19:34:06 +08:00
时迁酱
dfa36d872e Update README.md 2025-09-22 12:08:35 +08:00
sqj
995859e661 1 2025-09-22 03:54:49 +08:00
sqj
34fb0f7c2f fix:qqLyric 2025-09-22 03:41:08 +08:00
59 changed files with 6921 additions and 35764 deletions

View File

@@ -1,275 +0,0 @@
import {
useMediaQuery
} from "./chunk-B6YPYVPP.js";
import {
computed,
ref,
shallowRef,
watch
} from "./chunk-I4O5PVBA.js";
// node_modules/vitepress/dist/client/theme-default/index.js
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/fonts.css";
// node_modules/vitepress/dist/client/theme-default/without-fonts.js
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/vars.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/base.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/icons.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/utils.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/custom-block.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code-group.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-doc.css";
import "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/styles/components/vp-sponsor.css";
import VPBadge from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import Layout from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/Layout.vue";
import { default as default2 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue";
import { default as default3 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPButton.vue";
import { default as default4 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPDocAsideSponsors.vue";
import { default as default5 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPFeatures.vue";
import { default as default6 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeContent.vue";
import { default as default7 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeFeatures.vue";
import { default as default8 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeHero.vue";
import { default as default9 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPHomeSponsors.vue";
import { default as default10 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPImage.vue";
import { default as default11 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPLink.vue";
import { default as default12 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPNavBarSearch.vue";
import { default as default13 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPSocialLink.vue";
import { default as default14 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPSocialLinks.vue";
import { default as default15 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPSponsors.vue";
import { default as default16 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamMembers.vue";
import { default as default17 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamPage.vue";
import { default as default18 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageSection.vue";
import { default as default19 } from "D:/code/CeruMuisc/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageTitle.vue";
// node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
import { onContentUpdated } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/composables/outline.js
import { getScrollOffset } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/support/utils.js
import { withBase } from "vitepress";
// node_modules/vitepress/dist/client/theme-default/composables/data.js
import { useData as useData$ } from "vitepress";
var useData = useData$;
// node_modules/vitepress/dist/client/theme-default/support/utils.js
function ensureStartingSlash(path) {
return path.startsWith("/") ? path : `/${path}`;
}
// node_modules/vitepress/dist/client/theme-default/support/sidebar.js
function getSidebar(_sidebar, path) {
if (Array.isArray(_sidebar))
return addBase(_sidebar);
if (_sidebar == null)
return [];
path = ensureStartingSlash(path);
const dir = Object.keys(_sidebar).sort((a, b) => {
return b.split("/").length - a.split("/").length;
}).find((dir2) => {
return path.startsWith(ensureStartingSlash(dir2));
});
const sidebar = dir ? _sidebar[dir] : [];
return Array.isArray(sidebar) ? addBase(sidebar) : addBase(sidebar.items, sidebar.base);
}
function getSidebarGroups(sidebar) {
const groups = [];
let lastGroupIndex = 0;
for (const index in sidebar) {
const item = sidebar[index];
if (item.items) {
lastGroupIndex = groups.push(item);
continue;
}
if (!groups[lastGroupIndex]) {
groups.push({ items: [] });
}
groups[lastGroupIndex].items.push(item);
}
return groups;
}
function addBase(items, _base) {
return [...items].map((_item) => {
const item = { ..._item };
const base = item.base || _base;
if (base && item.link)
item.link = base + item.link;
if (item.items)
item.items = addBase(item.items, base);
return item;
});
}
// node_modules/vitepress/dist/client/theme-default/composables/sidebar.js
function useSidebar() {
const { frontmatter, page, theme: theme2 } = useData();
const is960 = useMediaQuery("(min-width: 960px)");
const isOpen = ref(false);
const _sidebar = computed(() => {
const sidebarConfig = theme2.value.sidebar;
const relativePath = page.value.relativePath;
return sidebarConfig ? getSidebar(sidebarConfig, relativePath) : [];
});
const sidebar = ref(_sidebar.value);
watch(_sidebar, (next, prev) => {
if (JSON.stringify(next) !== JSON.stringify(prev))
sidebar.value = _sidebar.value;
});
const hasSidebar = computed(() => {
return frontmatter.value.sidebar !== false && sidebar.value.length > 0 && frontmatter.value.layout !== "home";
});
const leftAside = computed(() => {
if (hasAside)
return frontmatter.value.aside == null ? theme2.value.aside === "left" : frontmatter.value.aside === "left";
return false;
});
const hasAside = computed(() => {
if (frontmatter.value.layout === "home")
return false;
if (frontmatter.value.aside != null)
return !!frontmatter.value.aside;
return theme2.value.aside !== false;
});
const isSidebarEnabled = computed(() => hasSidebar.value && is960.value);
const sidebarGroups = computed(() => {
return hasSidebar.value ? getSidebarGroups(sidebar.value) : [];
});
function open() {
isOpen.value = true;
}
function close() {
isOpen.value = false;
}
function toggle() {
isOpen.value ? close() : open();
}
return {
isOpen,
sidebar,
sidebarGroups,
hasSidebar,
hasAside,
leftAside,
isSidebarEnabled,
open,
close,
toggle
};
}
// node_modules/vitepress/dist/client/theme-default/composables/outline.js
var ignoreRE = /\b(?:VPBadge|header-anchor|footnote-ref|ignore-header)\b/;
var resolvedHeaders = [];
function getHeaders(range) {
const headers = [
...document.querySelectorAll(".VPDoc :where(h1,h2,h3,h4,h5,h6)")
].filter((el) => el.id && el.hasChildNodes()).map((el) => {
const level = Number(el.tagName[1]);
return {
element: el,
title: serializeHeader(el),
link: "#" + el.id,
level
};
});
return resolveHeaders(headers, range);
}
function serializeHeader(h) {
let ret = "";
for (const node of h.childNodes) {
if (node.nodeType === 1) {
if (ignoreRE.test(node.className))
continue;
ret += node.textContent;
} else if (node.nodeType === 3) {
ret += node.textContent;
}
}
return ret.trim();
}
function resolveHeaders(headers, range) {
if (range === false) {
return [];
}
const levelsRange = (typeof range === "object" && !Array.isArray(range) ? range.level : range) || 2;
const [high, low] = typeof levelsRange === "number" ? [levelsRange, levelsRange] : levelsRange === "deep" ? [2, 6] : levelsRange;
return buildTree(headers, high, low);
}
function buildTree(data, min, max) {
resolvedHeaders.length = 0;
const result = [];
const stack = [];
data.forEach((item) => {
const node = { ...item, children: [] };
let parent = stack[stack.length - 1];
while (parent && parent.level >= node.level) {
stack.pop();
parent = stack[stack.length - 1];
}
if (node.element.classList.contains("ignore-header") || parent && "shouldIgnore" in parent) {
stack.push({ level: node.level, shouldIgnore: true });
return;
}
if (node.level > max || node.level < min)
return;
resolvedHeaders.push({ element: node.element, link: node.link });
if (parent)
parent.children.push(node);
else
result.push(node);
stack.push(node);
});
return result;
}
// node_modules/vitepress/dist/client/theme-default/composables/local-nav.js
function useLocalNav() {
const { theme: theme2, frontmatter } = useData();
const headers = shallowRef([]);
const hasLocalNav = computed(() => {
return headers.value.length > 0;
});
onContentUpdated(() => {
headers.value = getHeaders(frontmatter.value.outline ?? theme2.value.outline);
});
return {
headers,
hasLocalNav
};
}
// node_modules/vitepress/dist/client/theme-default/without-fonts.js
var theme = {
Layout,
enhanceApp: ({ app }) => {
app.component("Badge", VPBadge);
}
};
var without_fonts_default = theme;
export {
default2 as VPBadge,
default3 as VPButton,
default4 as VPDocAsideSponsors,
default5 as VPFeatures,
default6 as VPHomeContent,
default7 as VPHomeFeatures,
default8 as VPHomeHero,
default9 as VPHomeSponsors,
default10 as VPImage,
default11 as VPLink,
default12 as VPNavBarSearch,
default13 as VPSocialLink,
default14 as VPSocialLinks,
default15 as VPSponsors,
default16 as VPTeamMembers,
default17 as VPTeamPage,
default18 as VPTeamPageSection,
default19 as VPTeamPageTitle,
without_fonts_default as default,
useLocalNav,
useSidebar
};
//# sourceMappingURL=@theme_index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1,40 +0,0 @@
{
"hash": "99cf66da",
"configHash": "acc3a95b",
"lockfileHash": "6f0f9736",
"browserHash": "6e863def",
"optimized": {
"vue": {
"src": "../../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "4f939392",
"needsInterop": false
},
"vitepress > @vue/devtools-api": {
"src": "../../../node_modules/@vue/devtools-api/dist/index.js",
"file": "vitepress___@vue_devtools-api.js",
"fileHash": "fcdf6679",
"needsInterop": false
},
"vitepress > @vueuse/core": {
"src": "../../../node_modules/@vueuse/core/index.mjs",
"file": "vitepress___@vueuse_core.js",
"fileHash": "f6cccf57",
"needsInterop": false
},
"@theme/index": {
"src": "../../../node_modules/vitepress/dist/client/theme-default/index.js",
"file": "@theme_index.js",
"fileHash": "1995bc33",
"needsInterop": false
}
},
"chunks": {
"chunk-B6YPYVPP": {
"file": "chunk-B6YPYVPP.js"
},
"chunk-I4O5PVBA": {
"file": "chunk-I4O5PVBA.js"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,583 +0,0 @@
import {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
} from "./chunk-B6YPYVPP.js";
import "./chunk-I4O5PVBA.js";
export {
DefaultMagicKeysAliasMap,
StorageSerializers,
TransitionPresets,
assert,
computedAsync as asyncComputed,
refAutoReset as autoResetRef,
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsElement,
breakpointsMasterCss,
breakpointsPrimeFlex,
breakpointsQuasar,
breakpointsSematic,
breakpointsTailwind,
breakpointsVuetify,
breakpointsVuetifyV2,
breakpointsVuetifyV3,
bypassFilter,
camelize,
clamp,
cloneFnJSON,
computedAsync,
computedEager,
computedInject,
computedWithControl,
containsProp,
computedWithControl as controlledComputed,
controlledRef,
createEventHook,
createFetch,
createFilterWrapper,
createGlobalState,
createInjectionState,
reactify as createReactiveFn,
createRef,
createReusableTemplate,
createSharedComposable,
createSingletonPromise,
createTemplatePromise,
createUnrefFn,
customStorageEventName,
debounceFilter,
refDebounced as debouncedRef,
watchDebounced as debouncedWatch,
defaultDocument,
defaultLocation,
defaultNavigator,
defaultWindow,
computedEager as eagerComputed,
executeTransition,
extendRef,
formatDate,
formatTimeAgo,
get,
getLifeCycleTarget,
getSSRHandler,
hasOwn,
hyphenate,
identity,
watchIgnorable as ignorableWatch,
increaseWithUnit,
injectLocal,
invoke,
isClient,
isDef,
isDefined,
isIOS,
isObject,
isWorker,
makeDestructurable,
mapGamepadToXbox360Controller,
noop,
normalizeDate,
notNullish,
now,
objectEntries,
objectOmit,
objectPick,
onClickOutside,
onElementRemoval,
onKeyDown,
onKeyPressed,
onKeyStroke,
onKeyUp,
onLongPress,
onStartTyping,
pausableFilter,
watchPausable as pausableWatch,
promiseTimeout,
provideLocal,
provideSSRWidth,
pxValue,
rand,
reactify,
reactifyObject,
reactiveComputed,
reactiveOmit,
reactivePick,
refAutoReset,
refDebounced,
refDefault,
refThrottled,
refWithControl,
resolveRef,
resolveUnref,
set,
setSSRHandler,
syncRef,
syncRefs,
templateRef,
throttleFilter,
refThrottled as throttledRef,
watchThrottled as throttledWatch,
timestamp,
toArray,
toReactive,
toRef,
toRefs,
toValue,
tryOnBeforeMount,
tryOnBeforeUnmount,
tryOnMounted,
tryOnScopeDispose,
tryOnUnmounted,
unrefElement,
until,
useActiveElement,
useAnimate,
useArrayDifference,
useArrayEvery,
useArrayFilter,
useArrayFind,
useArrayFindIndex,
useArrayFindLast,
useArrayIncludes,
useArrayJoin,
useArrayMap,
useArrayReduce,
useArraySome,
useArrayUnique,
useAsyncQueue,
useAsyncState,
useBase64,
useBattery,
useBluetooth,
useBreakpoints,
useBroadcastChannel,
useBrowserLocation,
useCached,
useClipboard,
useClipboardItems,
useCloned,
useColorMode,
useConfirmDialog,
useCountdown,
useCounter,
useCssVar,
useCurrentElement,
useCycleList,
useDark,
useDateFormat,
refDebounced as useDebounce,
useDebounceFn,
useDebouncedRefHistory,
useDeviceMotion,
useDeviceOrientation,
useDevicePixelRatio,
useDevicesList,
useDisplayMedia,
useDocumentVisibility,
useDraggable,
useDropZone,
useElementBounding,
useElementByPoint,
useElementHover,
useElementSize,
useElementVisibility,
useEventBus,
useEventListener,
useEventSource,
useEyeDropper,
useFavicon,
useFetch,
useFileDialog,
useFileSystemAccess,
useFocus,
useFocusWithin,
useFps,
useFullscreen,
useGamepad,
useGeolocation,
useIdle,
useImage,
useInfiniteScroll,
useIntersectionObserver,
useInterval,
useIntervalFn,
useKeyModifier,
useLastChanged,
useLocalStorage,
useMagicKeys,
useManualRefHistory,
useMediaControls,
useMediaQuery,
useMemoize,
useMemory,
useMounted,
useMouse,
useMouseInElement,
useMousePressed,
useMutationObserver,
useNavigatorLanguage,
useNetwork,
useNow,
useObjectUrl,
useOffsetPagination,
useOnline,
usePageLeave,
useParallax,
useParentElement,
usePerformanceObserver,
usePermission,
usePointer,
usePointerLock,
usePointerSwipe,
usePreferredColorScheme,
usePreferredContrast,
usePreferredDark,
usePreferredLanguages,
usePreferredReducedMotion,
usePreferredReducedTransparency,
usePrevious,
useRafFn,
useRefHistory,
useResizeObserver,
useSSRWidth,
useScreenOrientation,
useScreenSafeArea,
useScriptTag,
useScroll,
useScrollLock,
useSessionStorage,
useShare,
useSorted,
useSpeechRecognition,
useSpeechSynthesis,
useStepper,
useStorage,
useStorageAsync,
useStyleTag,
useSupported,
useSwipe,
useTemplateRefsList,
useTextDirection,
useTextSelection,
useTextareaAutosize,
refThrottled as useThrottle,
useThrottleFn,
useThrottledRefHistory,
useTimeAgo,
useTimeout,
useTimeoutFn,
useTimeoutPoll,
useTimestamp,
useTitle,
useToNumber,
useToString,
useToggle,
useTransition,
useUrlSearchParams,
useUserMedia,
useVModel,
useVModels,
useVibrate,
useVirtualList,
useWakeLock,
useWebNotification,
useWebSocket,
useWebWorker,
useWebWorkerFn,
useWindowFocus,
useWindowScroll,
useWindowSize,
watchArray,
watchAtMost,
watchDebounced,
watchDeep,
watchIgnorable,
watchImmediate,
watchOnce,
watchPausable,
watchThrottled,
watchTriggerable,
watchWithFilter,
whenever
};
//# sourceMappingURL=vitepress___@vueuse_core.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@@ -1,343 +0,0 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-I4O5PVBA.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@@ -11,6 +11,7 @@ Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=timeshiftsauce/CeruMusic&type=Date)](https://www.star-history.com/#timeshiftsauce/CeruMusic&Date)
## 技术栈
- **Electron**:用于构建跨平台桌面应用

View File

@@ -7,8 +7,8 @@ export default defineConfig({
base: '/',
description:
'Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,一个跨平台的音乐播放器应用,支持基于合规插件获取公开音乐信息与播放功能。',
markdown:{
config(md){
markdown: {
config(md) {
md.use(note)
}
},
@@ -28,13 +28,11 @@ export default defineConfig({
{ text: '安装教程', link: '/guide/' },
{
text: '使用教程',
items: [
{ text: '音乐播放列表', link: '/guide/used/playList' },
]
items: [{ text: '音乐播放列表', link: '/guide/used/playList' }]
},
{ text: '软件设计文档', link: '/guide/design' },
{ text: '更新日志', link: '/guide/updateLog' },
{ text: '更新计划', link: '/guide/update'}
{ text: '更新计划', link: '/guide/update' }
]
},
{
@@ -43,6 +41,9 @@ export default defineConfig({
{ text: '插件类使用', link: '/guide/CeruMusicPluginHost' },
{ text: '澜音插件开发文档(重点)', link: '/guide/CeruMusicPluginDev' }
]
},{
text: '鸣谢名单',
link: '/guide/sponsorship'
}
],
@@ -64,21 +65,20 @@ export default defineConfig({
provider: 'local'
},
outline: {
level: [2,4],
level: [2, 4],
label: '文章导航'
},
docFooter: {
next: '下一篇',
prev: '上一篇'
},
lastUpdatedText: '上次更新',
lastUpdatedText: '上次更新'
},
sitemap: {
hostname: 'https://ceru.docs.shiqianjiang.cn'
},
lastUpdated: true,
head: [['link', { rel: 'icon', href: '/logo.svg' }]],
head: [['link', { rel: 'icon', href: '/logo.svg' }]]
})
console.log(process.env.BASE_URL_DOCS)
// Smooth scrolling functions

View File

@@ -168,15 +168,15 @@ html.dark #app {
// --autonum-h3: counter(h1) "." counter(h2) "." counter(h3) ". ";
// --autonum-h4: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) ". ";
// --autonum-h5: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) ". ";
// --autonum-h6: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". ";
// --autonum-h6: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". ";
/* 下面是文章内Toc目录自动编号与上面一样即可 */
// --autonum-h1toc: counter(h1toc) ". ";
// --autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
// --autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
// --autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
// --autonum-h5toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) ". ";
// --autonum-h6toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) "." counter(h6toc) ". ";
// --autonum-h1toc: counter(h1toc) ". ";
// --autonum-h2toc: counter(h1toc) "." counter(h2toc) ". ";
// --autonum-h3toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) ". ";
// --autonum-h4toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) ". ";
// --autonum-h5toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) ". ";
// --autonum-h6toc: counter(h1toc) "." counter(h2toc) "." counter(h3toc) "." counter(h4toc) "." counter(h5toc) "." counter(h6toc) ". ";
/* 主题颜色 */
@@ -284,4 +284,4 @@ html .vp-doc div[class*='language-'] pre {
}
.VPDoc.has-aside .content-container {
max-width: none !important;
}
}

View File

@@ -3,6 +3,7 @@
## 概述
CeruMusic 支持两种类型的插件:
1. **CeruMusic 原生插件**:基于 CeruMusic API 的插件格式
2. **LX 兼容插件**:兼容 LX Music 的事件驱动插件格式
@@ -67,7 +68,7 @@ const sources = {
qualities: ['128k', '320k', 'flac', 'flac24bit']
},
tx:{
name: "QQ音乐",
name: "QQ音乐",
qualities: ['128k', '320k', 'flac']
}
};
@@ -132,23 +133,21 @@ module.exports = {
> #### PS:
>
> - `sources key` 取值
>
> - wy 网易云音乐 |
> - tx QQ音乐 |
> - kg 酷狗音乐 |
> - mg 咪咕音乐 |
> - tx QQ音乐 |
> - kg 酷狗音乐 |
> - mg 咪咕音乐 |
> - kw 酷我音乐
>
> - 导出
>
> ```javascript
> module.exports = {
> sources, // 你的音源支持
> };
> sources // 你的音源支持
> }
> ```
>
> - 支持的音质 ` sources.qualities: ['128k', '320k', 'flac']`
>
> - `128k`: 128kbps
> - `320k`: 320kbps
> - `flac`: FLAC 无损
@@ -157,8 +156,6 @@ module.exports = {
> - `atmos`: 杜比全景声
> - `master`: 母带音质
### CeruMusic API 参考
#### cerumusic.request(url, options)
@@ -166,6 +163,7 @@ module.exports = {
HTTP 请求方法,返回 Promise。
**参数:**
- `url` (string): 请求地址
- `options` (object): 请求选项
- `method`: 请求方法 (GET, POST, PUT, DELETE 等)
@@ -174,6 +172,7 @@ HTTP 请求方法,返回 Promise。
- `timeout`: 超时时间(毫秒)
**返回值:**
```javascript
{
statusCode: 200,
@@ -206,16 +205,17 @@ cerumusic.utils.crypto.rsaEncrypt(data, key)
cerumusic.NoticeCenter('info', {
title: '通知标题',
content: '通知内容',
url: 'https://example.com', // 可选 当通知为update 版本跟新可传
url: 'https://example.com', // 可选 当通知为update 版本跟新可传
version: '版本号', // 当通知为update 版本跟新可传
pluginInfo: {
name: '插件名称',
type: 'cr', // 固定唯一标识
}// 当通知为update 版本跟新可传
});
type: 'cr' // 固定唯一标识
} // 当通知为update 版本跟新可传
})
```
**通知类型:**
- `'info'`: 信息通知
- `'success'`: 成功通知
- `'warn'`: 警告通知
@@ -247,46 +247,47 @@ const qualitys = {
'128k': '128',
'320k': '320',
flac: 'flac',
flac24bit: 'flac24bit',
flac24bit: 'flac24bit'
},
local: {},
local: {}
}
// HTTP 请求封装
const httpRequest = (url, options) => new Promise((resolve, reject) => {
request(url, options, (err, resp) => {
if (err) return reject(err)
resolve(resp.body)
const httpRequest = (url, options) =>
new Promise((resolve, reject) => {
request(url, options, (err, resp) => {
if (err) return reject(err)
resolve(resp.body)
})
})
})
// API 实现
const apis = {
kw: {
musicUrl({ songmid }, quality) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return data.url
})
},
}
},
local: {
musicUrl(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return data.url
})
},
pic(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return data.url
})
},
lyric(info) {
return httpRequest('http://xxx').then(data => {
return httpRequest('http://xxx').then((data) => {
return {
lyric: '...', // 歌曲歌词
tlyric: '...', // 翻译歌词,没有可为 null
rlyric: '...', // 罗马音歌词,没有可为 null
lxlyric: '...', // lx 逐字歌词,没有可为 null
lxlyric: '...' // lx 逐字歌词,没有可为 null
}
})
}
@@ -313,15 +314,15 @@ send(EVENT_NAMES.inited, {
name: '酷我音乐',
type: 'music',
actions: ['musicUrl'],
qualitys: ['128k', '320k', 'flac', 'flac24bit'],
qualitys: ['128k', '320k', 'flac', 'flac24bit']
},
local: {
name: '本地音乐',
type: 'music',
actions: ['musicUrl', 'lyric', 'pic'],
qualitys: [],
},
},
qualitys: []
}
}
})
```
@@ -342,8 +343,8 @@ send(EVENT_NAMES.inited, {
```javascript
lx.on(lx.EVENT_NAMES.request, ({ source, action, info }) => {
// 必须返回 Promise
return Promise.resolve(result);
});
return Promise.resolve(result)
})
```
#### globalThis.lx.send(eventName, data)
@@ -369,18 +370,22 @@ lx.send(lx.EVENT_NAMES.updateAlert, {
HTTP 请求方法:
```javascript
lx.request('https://api.example.com', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
timeout: 10000
}, (err, resp) => {
if (err) {
console.error('请求失败:', err);
return;
lx.request(
'https://api.example.com',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
timeout: 10000
},
(err, resp) => {
if (err) {
console.error('请求失败:', err)
return
}
console.log('响应:', resp.body)
}
console.log('响应:', resp.body);
});
)
```
#### globalThis.lx.utils
@@ -433,28 +438,28 @@ async function musicUrl(source, musicInfo, quality) {
try {
// 参数验证
if (!musicInfo || !musicInfo.id) {
throw new Error('音乐信息不完整');
throw new Error('音乐信息不完整')
}
// API 调用
const result = await cerumusic.request(url, options);
const result = await cerumusic.request(url, options)
// 结果验证
if (!result || result.statusCode !== 200) {
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`);
throw new Error(`API 请求失败: ${result?.statusCode || 'Unknown'}`)
}
if (!result.body || !result.body.url) {
throw new Error('返回数据格式错误');
throw new Error('返回数据格式错误')
}
return result.body.url;
return result.body.url
} catch (error) {
// 记录错误日志
console.error(`[${source}] 获取音乐链接失败:`, error.message);
console.error(`[${source}] 获取音乐链接失败:`, error.message)
// 重新抛出错误供上层处理
throw new Error(`获取 ${source} 音乐链接失败: ${error.message}`);
throw new Error(`获取 ${source} 音乐链接失败: ${error.message}`)
}
}
```
@@ -473,9 +478,9 @@ async function musicUrl(source, musicInfo, quality) {
### 1. 使用 console.log
```javascript
console.log('[插件名] 调试信息:', data);
console.warn('[插件名] 警告信息:', warning);
console.error('[插件名] 错误信息:', error);
console.log('[插件名] 调试信息:', data)
console.warn('[插件名] 警告信息:', warning)
console.error('[插件名] 错误信息:', error)
```
### 2. LX 插件开发者工具
@@ -491,8 +496,8 @@ send(EVENT_NAMES.inited, {
```javascript
process.on('unhandledRejection', (reason, promise) => {
console.error('未处理的 Promise 拒绝:', reason);
});
console.error('未处理的 Promise 拒绝:', reason)
})
```
---
@@ -502,17 +507,17 @@ process.on('unhandledRejection', (reason, promise) => {
### 1. 请求缓存
```javascript
const cache = new Map();
const cache = new Map()
async function getCachedData(key, fetcher, ttl = 300000) {
const cached = cache.get(key);
const cached = cache.get(key)
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
return cached.data
}
const data = await fetcher();
cache.set(key, { data, timestamp: Date.now() });
return data;
const data = await fetcher()
cache.set(key, { data, timestamp: Date.now() })
return data
}
```
@@ -521,21 +526,21 @@ async function getCachedData(key, fetcher, ttl = 300000) {
```javascript
const result = await cerumusic.request(url, {
timeout: 10000 // 10秒超时
});
})
```
### 3. 并发控制
```javascript
// 限制并发请求数量
const semaphore = new Semaphore(3); // 最多3个并发请求
const semaphore = new Semaphore(3) // 最多3个并发请求
async function limitedRequest(url, options) {
await semaphore.acquire();
await semaphore.acquire()
try {
return await cerumusic.request(url, options);
return await cerumusic.request(url, options)
} finally {
semaphore.release();
semaphore.release()
}
}
```
@@ -549,14 +554,14 @@ async function limitedRequest(url, options) {
```javascript
function validateMusicInfo(musicInfo) {
if (!musicInfo || typeof musicInfo !== 'object') {
throw new Error('音乐信息格式错误');
throw new Error('音乐信息格式错误')
}
if (!musicInfo.id || typeof musicInfo.id !== 'string') {
throw new Error('音乐 ID 无效');
throw new Error('音乐 ID 无效')
}
return true;
return true
}
```
@@ -565,10 +570,10 @@ function validateMusicInfo(musicInfo) {
```javascript
function isValidUrl(url) {
try {
const urlObj = new URL(url);
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
const urlObj = new URL(url)
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
} catch {
return false;
return false
}
}
```
@@ -581,7 +586,7 @@ console.log('请求参数:', {
...params,
token: '***', // 隐藏敏感信息
password: '***'
});
})
```
---
@@ -605,13 +610,13 @@ async function testMusicUrl() {
id: 'test123',
name: '测试歌曲',
artist: '测试歌手'
};
}
try {
const url = await musicUrl('kw', testMusicInfo, '320k');
console.log('测试通过:', url);
const url = await musicUrl('kw', testMusicInfo, '320k')
console.log('测试通过:', url)
} catch (error) {
console.error('测试失败:', error);
console.error('测试失败:', error)
}
}
```
@@ -619,6 +624,7 @@ async function testMusicUrl() {
### 3. 版本管理
使用语义化版本号:
- `1.0.0`: 主版本.次版本.修订版本
- 主版本:不兼容的 API 修改
- 次版本:向下兼容的功能性新增
@@ -631,6 +637,7 @@ async function testMusicUrl() {
### Q: 插件加载失败怎么办?
A: 检查以下几点:
1. 文件编码是否为 UTF-8
2. 插件信息注释格式是否正确
3. JavaScript 语法是否有错误
@@ -645,20 +652,21 @@ A: CeruMusic 的请求方法不受浏览器跨域限制,可以直接请求任
A: 使用 `cerumusic.NoticeCenter` 事件通知用户更新:
```javascript
cerumusic.NoticeCenter('update',{
title:'新版本更新',
content:'xxxx',
cerumusic.NoticeCenter('update', {
title: '新版本更新',
content: 'xxxx',
version: 'v1.0.3',
url:'https://shiqianjiang.cn',
pluginInfo:{
type:'cr'
url: 'https://shiqianjiang.cn',
pluginInfo: {
type: 'cr'
}
})
```
### Q: 如何调试插件?
A:
A:
1. 使用 `console.log` 输出调试信息 可在设置—>插件管理—>日志 查看调试
2. LX 插件可以设置 `openDevTools: true` 打开开发者工具
3. 查看 CeruMusic 的插件日志
@@ -668,5 +676,6 @@ A:
## 技术支持
如有问题或建议,请通过以下方式联系:
- GitHub Issues: [CeruMusic Issues](https://github.com/timeshiftsauce/CeruMusic/issues)
- Blog (最好登录,否则需要审核): [CeruMusic Blog](https://shiqianjiang.cn/blog/4966904626407280640)

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,8 @@
# 赞助名单
## 鸣谢
| 昵称 | 赞助金额 |
| :------------------------: | :------: |
| **群友**:可惜情绪落泪零碎 | 6.66 |

View File

@@ -2,10 +2,14 @@
- [ ] 搜索框可以弄对应的音乐源的排行榜 搜索联想
- [ ] 导航上面这几个按钮可以稍微优化一下
- [ ] 大熊好暖: 09-21 12:52:25 在搜索歌曲出来后 大熊好暖: 09-21 12:52:34 一般都是双击播放 大熊好暖: 09-21 12:54:16 这个是双击加入播放列表 可以优化 或者可以把这个双击做一个选项,让人选择
- [ ] 大熊好暖: 09-21 12:52:25 在搜索歌曲出来后 大熊好暖: 09-21 12:52:34 一般都是双击播放 大熊好暖: 09-21 12:54:16 这个是双击加入播放列表 可以优化 或者可以把这个双击做一个选项,让人选择
- [ ] 播放页 样式模板选择 歌词背景律动很有沉浸感,但是黑胶唱片的专辑样式不是很喜欢,希望增加一个选项如图二一样只有圆形专辑封面
- [x] 点击搜索框的 源图标实现快速切换
- [ ] ai功能完善
- [ ] 支持歌词隐藏
- [x] 兼容多平台歌单导入
- [x] 软件能不能记住上次打开的窗口大小,每次都要手动拉
- [x] 歌单右键菜单
- [x] 播放列表滚动条适配
- [ ] 暗色主题
- [x] 歌单页支持修改封面

View File

@@ -1,11 +1,45 @@
# 澜音版本更新日志
## 日志
- ###### 2025-9-17 **(V1.3.2)**
- ###### 2025-9-26 (v1.3.8)
1. 写入歌曲tag信息
2. 歌曲下载 选择音质
3. 歌单 头部自动压缩
- ###### 2025-9-25 (v1.3.7)
1. 歌单
- 新增右键移除歌曲
- local 页歌单右键操作
- 歌单页支持修改封面
2. debug右键菜单二级菜单位置决策
- ###### 2025-9-22 (v1.3.6)
1. 歌单列表可以右键操作
- 播放
- 下载
- 添加到歌单
- 添加到播放列表
2. 播放列表滚动条
3. 搜索页切换源重新加载
- ###### 2025-9-22 (v1.3.5)
1. 软件启动位置 宽高记忆 限制软件最大宽高
2. debug: 修复歌曲音质支持短缺问题
- ###### 2025-9-21 (v1.3.4)
1. 紧急修复QQ音乐歌词失效问题
- ###### 2025-9-21(v1.3.3)
1. 兼容多平台歌单导入
2. 点击搜索框的 源图标实现快速切换
3. debug: fix:列表删除按钮冒泡
- ###### 2025-9-17 **(v1.3.2)**
1. 目录结构调整
2. **支持插件更新提示**
@@ -13,13 +47,11 @@
**洛雪** 插件请手动重装适配
3. **debug**
- SMTC 问题
- 歌曲缓存播放多次请求和多次缓存问题
- ###### 2025-9-17 **V1.3.1**
- ###### 2025-9-17 **v1.3.1**
1. **设置功能页**
- 缓存路径支持自定义
- 下载路径支持自定义
@@ -27,4 +59,4 @@
- 播放页面唱针可以拖动问题
- 播放按钮加载中 因为自动下一曲 导致动画变形问题
- **SMTC** 功能 系统显示**未知应用**问题
- 播放页歌词**字体粗细**偶现丢失问题
- 播放页歌词**字体粗细**偶现丢失问题

View File

@@ -23,6 +23,4 @@
歌单将自动选取第一首 **有效封面**[^1] 为歌单
[^1]: url正确的歌曲封面
[^1]: url正确的歌曲封面

View File

@@ -2,24 +2,24 @@
// 这个文件可以用来测试 NoticeCenter 功能
const pluginInfo = {
name: "测试通知插件",
version: "1.0.0",
author: "CeruMusic Team",
description: "用于测试插件通知功能的示例插件",
type: "cr"
name: '测试通知插件',
version: '1.0.0',
author: 'CeruMusic Team',
description: '用于测试插件通知功能的示例插件',
type: 'cr'
}
const sources = [
{
name: "test",
qualities: ["128k", "320k"]
name: 'test',
qualities: ['128k', '320k']
}
]
// 模拟音乐URL获取函数
async function musicUrl(source, musicInfo, quality) {
console.log('测试插件获取音乐URL')
// 测试不同类型的通知
setTimeout(() => {
// 测试信息通知
@@ -29,7 +29,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '插件正在正常工作'
})
}, 1000)
setTimeout(() => {
// 测试警告通知
this.cerumusic.NoticeCenter('warning', {
@@ -38,7 +38,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '请注意某些设置'
})
}, 2000)
setTimeout(() => {
// 测试成功通知
this.cerumusic.NoticeCenter('success', {
@@ -47,7 +47,7 @@ async function musicUrl(source, musicInfo, quality) {
content: '音乐URL获取成功'
})
}, 3000)
setTimeout(() => {
// 测试更新通知
this.cerumusic.NoticeCenter('update', {
@@ -62,7 +62,7 @@ async function musicUrl(source, musicInfo, quality) {
}
})
}, 4000)
setTimeout(() => {
// 测试错误通知
this.cerumusic.NoticeCenter('error', {
@@ -71,7 +71,7 @@ async function musicUrl(source, musicInfo, quality) {
error: '模拟的错误信息'
})
}, 5000)
// 返回一个测试URL
return 'https://example.com/test-music.mp3'
}
@@ -81,4 +81,4 @@ module.exports = {
pluginInfo,
sources,
musicUrl
}
}

View File

@@ -81,27 +81,27 @@ this.cerumusic.NoticeCenter('update', {
#### 通用参数 (data 对象)
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| title | string | 否 | 通知标题,不提供时使用默认标题 |
| message | string | 否 | 通知消息内容 |
| content | string | 否 | 详细内容(与 message 二选一) |
| 参数 | 类型 | 必填 | 说明 |
| ------- | ------ | ---- | ------------------------------ |
| title | string | 否 | 通知标题,不提供时使用默认标题 |
| message | string | 否 | 通知消息内容 |
| content | string | 否 | 详细内容(与 message 二选一) |
#### 更新通知特有参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| url | string | 是 | 插件更新下载链接 |
| version | string | 否 | 新版本号 |
| pluginInfo.name | string | 否 | 插件名称 |
| pluginInfo.type | 'cr' \| 'lx' | 否 | 插件类型 |
| pluginInfo.forcedUpdate | boolean | 否 | 是否强制更新 |
| 参数 | 类型 | 必填 | 说明 |
| ----------------------- | ------------ | ---- | ---------------- |
| url | string | 是 | 插件更新下载链接 |
| version | string | 否 | 新版本号 |
| pluginInfo.name | string | 否 | 插件名称 |
| pluginInfo.type | 'cr' \| 'lx' | 否 | 插件类型 |
| pluginInfo.forcedUpdate | boolean | 否 | 是否强制更新 |
#### 错误通知特有参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| error | string | 否 | 具体错误信息 |
| 参数 | 类型 | 必填 | 说明 |
| ----- | ------ | ---- | ------------ |
| error | string | 否 | 具体错误信息 |
## 实现原理
@@ -208,8 +208,9 @@ window.api.on('plugin-notice', (_, notice) => {
## 更新日志
### v1.0.0 (2025-09-20)
- ✨ 初始版本发布
- ✨ 支持 5 种通知类型
- ✨ 完整的 TypeScript 类型定义
- ✨ 响应式设计和深色主题支持
- ✨ 完善的错误处理机制
- ✨ 完善的错误处理机制

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.3.3",
"version": "1.3.8",
"description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js",
"author": "sqj,wldss,star",
@@ -8,7 +8,7 @@
"homepage": "https://ceru.docs.shiqianjiang.cn",
"scripts": {
"format": "prettier --write .",
"lint": "eslint --cache . --fix",
"lint": "eslint --cache . --fix && yarn typecheck",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "yarn run typecheck:node && yarn run typecheck:web",
@@ -63,7 +63,9 @@
"mitt": "^3.0.1",
"needle": "^3.3.1",
"node-fetch": "2",
"node-id3": "^0.2.9",
"pinia": "^3.0.3",
"tdesign-icons-vue-next": "^0.4.1",
"tdesign-vue-next": "^1.15.2",
"vue-router": "^4.5.1",
"zlib": "^1.0.5"

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
version: "1.0"
version: '1.0'
profile:
name: qodana.starter

View File

@@ -1,55 +1,79 @@
const fs = require('fs');
const path = require('path');
const fs = require('fs')
const path = require('path')
function generateTree(
dir,
prefix = '',
isLast = true,
excludeDirs = [
'node_modules',
'dist',
'out',
'.git',
'.kiro',
'.idea',
'.codebuddy',
'.vscode',
'.workflow',
'assets',
'resources',
'docs'
]
) {
const basename = path.basename(dir)
function generateTree(dir, prefix = '', isLast = true, excludeDirs = ['node_modules', 'dist', 'out', '.git','.kiro','.idea','.codebuddy','.vscode','.workflow','assets','resources','docs']) {
const basename = path.basename(dir);
// 跳过排除的目录和隐藏文件
if (basename.startsWith('.') && basename !== '.' && basename !== '..' && !['.github', '.workflow'].includes(basename)) {
return;
if (
basename.startsWith('.') &&
basename !== '.' &&
basename !== '..' &&
!['.github', '.workflow'].includes(basename)
) {
return
}
if (excludeDirs.includes(basename)) {
return;
return
}
// 当前项目显示
if (prefix === '') {
console.log(`${basename}/`);
console.log(`${basename}/`)
} else {
const connector = isLast ? '└── ' : '├── ';
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename;
console.log(prefix + connector + displayName);
const connector = isLast ? '└── ' : '├── '
const displayName = fs.statSync(dir).isDirectory() ? `${basename}/` : basename
console.log(prefix + connector + displayName)
}
if (!fs.statSync(dir).isDirectory()) {
return;
return
}
try {
const items = fs.readdirSync(dir)
.filter(item => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
.filter(item => !excludeDirs.includes(item))
const items = fs
.readdirSync(dir)
.filter((item) => !item.startsWith('.') || ['.github', '.workflow'].includes(item))
.filter((item) => !excludeDirs.includes(item))
.sort((a, b) => {
// 目录排在前面,文件排在后面
const aIsDir = fs.statSync(path.join(dir, a)).isDirectory();
const bIsDir = fs.statSync(path.join(dir, b)).isDirectory();
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.localeCompare(b);
});
const newPrefix = prefix + (isLast ? ' ' : '│ ');
const aIsDir = fs.statSync(path.join(dir, a)).isDirectory()
const bIsDir = fs.statSync(path.join(dir, b)).isDirectory()
if (aIsDir && !bIsDir) return -1
if (!aIsDir && bIsDir) return 1
return a.localeCompare(b)
})
const newPrefix = prefix + (isLast ? ' ' : '│ ')
items.forEach((item, index) => {
const isLastItem = index === items.length - 1;
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs);
});
const isLastItem = index === items.length - 1
generateTree(path.join(dir, item), newPrefix, isLastItem, excludeDirs)
})
} catch (error) {
console.error(`Error reading directory: ${dir}`, error.message);
console.error(`Error reading directory: ${dir}`, error.message)
}
}
// 使用示例
const targetDir = process.argv[2] || '.';
console.log('项目文件结构:');
generateTree(targetDir);
const targetDir = process.argv[2] || '.'
console.log('项目文件结构:')
generateTree(targetDir)

View File

@@ -1,61 +1,19 @@
import { ipcMain, dialog, app } from 'electron'
import { join } from 'path'
import fs from 'fs'
import { promisify } from 'util'
const mkdir = promisify(fs.mkdir)
const access = promisify(fs.access)
export const CONFIG_NAME = 'sqj_config.json'
// 默认目录配置
const getDefaultDirectories = () => {
const userDataPath = app.getPath('userData')
return {
cacheDir: join(userDataPath, 'music-cache'),
downloadDir: join(app.getPath('music'), 'CeruMusic/songs')
}
}
// 确保目录存在
const ensureDirectoryExists = async (dirPath: string) => {
try {
await access(dirPath)
} catch {
await mkdir(dirPath, { recursive: true })
}
}
import { ipcMain, dialog } from 'electron'
import { configManager } from '../services/ConfigManager'
// 获取当前目录配置
ipcMain.handle('directory-settings:get-directories', async () => {
try {
const defaults = getDefaultDirectories()
// 从配置文件读取用户设置的目录
const configPath = join(app.getPath('userData'), CONFIG_NAME)
let userConfig: any = {}
try {
const configData = fs.readFileSync(configPath, 'utf-8')
userConfig = JSON.parse(configData)
} catch {
// 配置文件不存在或读取失败,使用默认配置
}
const directories = {
cacheDir: userConfig.cacheDir || defaults.cacheDir,
downloadDir: userConfig.downloadDir || defaults.downloadDir
}
const directories = configManager.getDirectories()
// 确保目录存在
await ensureDirectoryExists(directories.cacheDir)
await ensureDirectoryExists(directories.downloadDir)
await configManager.ensureDirectoryExists(directories.cacheDir)
await configManager.ensureDirectoryExists(directories.downloadDir)
return directories
} catch (error) {
console.error('获取目录配置失败:', error)
const defaults = getDefaultDirectories()
return defaults
return configManager.getDirectories() // 返回默认配置
}
})
@@ -70,7 +28,7 @@ ipcMain.handle('directory-settings:select-cache-dir', async () => {
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0]
await ensureDirectoryExists(selectedPath)
await configManager.ensureDirectoryExists(selectedPath)
return { success: true, path: selectedPath }
}
@@ -92,7 +50,7 @@ ipcMain.handle('directory-settings:select-download-dir', async () => {
if (!result.canceled && result.filePaths.length > 0) {
const selectedPath = result.filePaths[0]
await ensureDirectoryExists(selectedPath)
await configManager.ensureDirectoryExists(selectedPath)
return { success: true, path: selectedPath }
}
@@ -106,16 +64,8 @@ ipcMain.handle('directory-settings:select-download-dir', async () => {
// 保存目录配置
ipcMain.handle('directory-settings:save-directories', async (_, directories) => {
try {
const configPath = join(app.getPath('userData'), CONFIG_NAME)
// 确保目录存在
await ensureDirectoryExists(directories.cacheDir)
await ensureDirectoryExists(directories.downloadDir)
// 保存配置
fs.writeFileSync(configPath, JSON.stringify(directories, null, 2))
return { success: true, message: '目录配置已保存' }
const success = await configManager.saveDirectories(directories)
return { success, message: success ? '目录配置已保存' : '保存配置失败' }
} catch (error) {
console.error('保存目录配置失败:', error)
return { success: false, message: '保存配置失败' }
@@ -125,21 +75,19 @@ ipcMain.handle('directory-settings:save-directories', async (_, directories) =>
// 重置为默认目录
ipcMain.handle('directory-settings:reset-directories', async () => {
try {
const defaults = getDefaultDirectories()
const configPath = join(app.getPath('userData'), CONFIG_NAME)
// 重置目录配置
configManager.delete('cacheDir')
configManager.delete('downloadDir')
configManager.saveConfig()
// 删除配置文件
try {
fs.unlinkSync(configPath)
} catch {
// 文件不存在,忽略错误
}
// 获取默认目录
const directories = configManager.getDirectories()
// 确保默认目录存在
await ensureDirectoryExists(defaults.cacheDir)
await ensureDirectoryExists(defaults.downloadDir)
await configManager.ensureDirectoryExists(directories.cacheDir)
await configManager.ensureDirectoryExists(directories.downloadDir)
return { success: true, directories: defaults }
return { success: true, directories }
} catch (error) {
console.error('重置目录配置失败:', error)
return { success: false, message: '重置配置失败' }
@@ -161,6 +109,9 @@ ipcMain.handle('directory-settings:open-directory', async (_, dirPath) => {
// 获取目录大小
ipcMain.handle('directory-settings:get-directory-size', async (_, dirPath) => {
try {
const fs = require('fs')
const { join } = require('path')
const getDirectorySize = (dirPath: string): number => {
let totalSize = 0

View File

@@ -1,4 +1,5 @@
import { app, shell, BrowserWindow, ipcMain, Tray, Menu } from 'electron'
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, screen } from 'electron'
import { configManager } from './services/ConfigManager'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/logo.png?asset'
@@ -89,20 +90,27 @@ function createTray(): void {
function createWindow(): void {
// return
// Create the browser window.
mainWindow = new BrowserWindow({
// 获取保存的窗口位置和大小
const savedBounds = configManager.getWindowBounds()
// 获取屏幕尺寸
const primaryDisplay = screen.getPrimaryDisplay()
// 使用完整屏幕尺寸而不是工作区域,以支持真正的全屏模式
const { width: screenWidth, height: screenHeight } = primaryDisplay.size
// 默认窗口配置
const defaultOptions = {
width: 1100,
height: 750,
minWidth: 1100,
minHeight: 670,
maxWidth: screenWidth,
maxHeight: screenHeight,
show: false,
center: true,
center: !savedBounds, // 如果有保存的位置,则不居中
autoHideMenuBar: true,
// alwaysOnTop: true,
// 移除最大宽高限制,以便全屏模式能够铺满整个屏幕
titleBarStyle: 'hidden',
titleBarStyle: 'hidden' as const,
...(process.platform === 'linux' ? { icon } : {}),
// ...(process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
icon: path.join(__dirname, '../../resources/logo.ico'),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
@@ -112,9 +120,57 @@ function createWindow(): void {
contextIsolation: false,
backgroundThrottling: false
}
})
}
// 如果有保存的窗口位置和大小,则使用保存的值
if (savedBounds) {
Object.assign(defaultOptions, savedBounds)
}
// Create the browser window.
mainWindow = new BrowserWindow(defaultOptions)
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
// 监听窗口移动和调整大小事件,保存窗口位置和大小
mainWindow.on('moved', () => {
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
const bounds = mainWindow.getBounds()
configManager.saveWindowBounds(bounds)
}
})
mainWindow.on('resized', () => {
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
const bounds = mainWindow.getBounds()
// 获取当前屏幕尺寸
const { screen } = require('electron')
const currentDisplay = screen.getDisplayMatching(bounds)
const { width: screenWidth, height: screenHeight } = currentDisplay.workAreaSize
// 确保窗口不超过屏幕尺寸
let needResize = false
const newBounds = { ...bounds }
if (bounds.width > screenWidth) {
newBounds.width = screenWidth
needResize = true
}
if (bounds.height > screenHeight) {
newBounds.height = screenHeight
needResize = true
}
// 如果需要调整大小,应用新的尺寸
if (needResize) {
mainWindow.setBounds(newBounds)
}
configManager.saveWindowBounds(newBounds)
}
})
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
})

View File

@@ -0,0 +1,162 @@
import { app } from 'electron'
import { join } from 'path'
import fs from 'fs'
import { promisify } from 'util'
const mkdir = promisify(fs.mkdir)
const access = promisify(fs.access)
export const CONFIG_NAME = 'sqj_config.json'
// 配置管理器类
export class ConfigManager {
private static instance: ConfigManager
private configPath: string
private config: Record<string, any> = {}
private constructor() {
this.configPath = join(app.getPath('userData'), CONFIG_NAME)
this.loadConfig()
}
// 单例模式获取实例
public static getInstance(): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager()
}
return ConfigManager.instance
}
// 加载配置
private loadConfig(): void {
try {
if (fs.existsSync(this.configPath)) {
const configData = fs.readFileSync(this.configPath, 'utf-8')
this.config = JSON.parse(configData)
}
} catch (error) {
console.error('加载配置失败:', error)
this.config = {}
}
}
// 保存配置
public saveConfig(): boolean {
try {
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2))
return true
} catch (error) {
console.error('保存配置失败:', error)
return false
}
}
// 获取配置项
public get<T>(key: string, defaultValue?: T): T {
const value = this.config[key]
return value !== undefined ? value : (defaultValue as T)
}
// 设置配置项
public set<T>(key: string, value: T): void {
this.config[key] = value
}
// 删除配置项
public delete(key: string): void {
delete this.config[key]
}
// 重置所有配置
public reset(): void {
this.config = {}
this.saveConfig()
}
// 获取所有配置
public getAll(): Record<string, any> {
return { ...this.config }
}
// 确保目录存在
public async ensureDirectoryExists(dirPath: string): Promise<void> {
try {
await access(dirPath)
} catch {
await mkdir(dirPath, { recursive: true })
}
}
// 获取目录配置
public getDirectories() {
const userDataPath = app.getPath('userData')
const defaults = {
cacheDir: join(userDataPath, 'music-cache'),
downloadDir: join(app.getPath('music'), 'CeruMusic/songs')
}
return {
cacheDir: this.get('cacheDir', defaults.cacheDir),
downloadDir: this.get('downloadDir', defaults.downloadDir)
}
}
// 保存目录配置
public async saveDirectories(directories: {
cacheDir: string
downloadDir: string
}): Promise<boolean> {
try {
await this.ensureDirectoryExists(directories.cacheDir)
await this.ensureDirectoryExists(directories.downloadDir)
this.set('cacheDir', directories.cacheDir)
this.set('downloadDir', directories.downloadDir)
return this.saveConfig()
} catch (error) {
console.error('保存目录配置失败:', error)
return false
}
}
// 保存窗口位置和大小
public saveWindowBounds(bounds: { x: number; y: number; width: number; height: number }): void {
this.set('windowBounds', bounds)
this.saveConfig()
}
// 获取窗口位置和大小,确保窗口完全在屏幕内
public getWindowBounds(): { x: number; y: number; width: number; height: number } | null {
const bounds = this.get<{ x: number; y: number; width: number; height: number } | null>(
'windowBounds',
null
)
if (bounds) {
const { screen } = require('electron')
// 获取主显示器
const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
// 确保窗口在屏幕内
if (bounds.x < 0) bounds.x = 0
if (bounds.y < 0) bounds.y = 0
// 确保窗口右侧不超出屏幕
if (bounds.x + bounds.width > screenWidth) {
bounds.x = Math.max(0, screenWidth - bounds.width)
}
// 确保窗口底部不超出屏幕
if (bounds.y + bounds.height > screenHeight) {
bounds.y = Math.max(0, screenHeight - bounds.height)
}
}
return bounds
}
}
// 导出单例实例
export const configManager = ConfigManager.getInstance()

View File

@@ -1,9 +1,8 @@
import { app } from 'electron'
import * as path from 'path'
import * as fs from 'fs/promises'
import * as crypto from 'crypto'
import axios from 'axios'
import { CONFIG_NAME } from '../../events/directorySettings'
import { configManager } from '../ConfigManager'
export class MusicCacheService {
private cacheIndex: Map<string, string> = new Map()
@@ -13,21 +12,9 @@ export class MusicCacheService {
}
private getCacheDirectory(): string {
try {
// 尝试从配置文件读取自定义缓存目录
const configPath = path.join(app.getPath('userData'), CONFIG_NAME)
const configData = require('fs').readFileSync(configPath, 'utf-8')
const config = JSON.parse(configData)
if (config.cacheDir && typeof config.cacheDir === 'string') {
return config.cacheDir
}
} catch {
// 配置文件不存在或读取失败,使用默认目录
}
// 默认缓存目录
return path.join(app.getPath('userData'), 'music-cache')
// 使用配置管理服务获取缓存目录
const directories = configManager.getDirectories()
return directories.cacheDir
}
// 动态获取缓存目录

View File

@@ -18,11 +18,222 @@ import fsPromise from 'fs/promises'
import axios from 'axios'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'url'
import { app } from 'electron'
import { CONFIG_NAME } from '../../events/directorySettings'
import { configManager } from '../ConfigManager'
import NodeID3 from 'node-id3'
const fileLock: Record<string, boolean> = {}
/**
* 转换LRC格式
* 将带有字符位置信息的LRC格式转换为标准的逐字时间戳格式
* @param lrcContent 原始LRC内容
* @returns 转换后的LRC内容
*/
function convertLrcFormat(lrcContent: string): string {
if (!lrcContent) return ''
const lines = lrcContent.split('\n')
const convertedLines: string[] = []
for (const line of lines) {
// 跳过空行
if (!line.trim()) {
convertedLines.push(line)
continue
}
// 检查是否是新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
const newFormatMatch = line.match(/^\[(\d+),(\d+)\](.*)$/)
if (newFormatMatch) {
const [, startTimeMs, , content] = newFormatMatch
const convertedLine = convertNewFormat(parseInt(startTimeMs), content)
convertedLines.push(convertedLine)
continue
}
// 检查是否是旧格式:[mm:ss.xxx]字符(偏移,持续时间)
const oldFormatMatch = line.match(/^\[(\d{2}:\d{2}\.\d{3})\](.*)$/)
if (oldFormatMatch) {
const [, timestamp, content] = oldFormatMatch
// 如果内容中没有位置信息,直接返回原行
if (!content.includes('(') || !content.includes(')')) {
convertedLines.push(line)
continue
}
const convertedLine = convertOldFormat(timestamp, content)
convertedLines.push(convertedLine)
continue
}
// 其他行直接保留
convertedLines.push(line)
}
return convertedLines.join('\n')
}
/**
* 将毫秒时间戳格式化为 mm:ss.xxx 格式
* @param timeMs 毫秒时间戳
* @returns 格式化的时间字符串
*/
function formatTimestamp(timeMs: number): string {
const minutes = Math.floor(timeMs / 60000)
const seconds = Math.floor((timeMs % 60000) / 1000)
const milliseconds = timeMs % 1000
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
}
/**
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
*/
function convertNewFormat(baseTimeMs: number, content: string): string {
const baseTimestamp = formatTimestamp(baseTimeMs)
let convertedContent = `<${baseTimestamp}>`
// 匹配模式:(开始时间,字符持续时间,0)字符
const charPattern = /\((\d+),(\d+),(\d+)\)([^(]*?)(?=\(|$)/g
let match
let isFirstChar = true
while ((match = charPattern.exec(content)) !== null) {
const [, charStartMs, , , char] = match
const charTimeMs = parseInt(charStartMs)
const charTimestamp = formatTimestamp(charTimeMs)
if (isFirstChar) {
// 第一个字符直接添加
convertedContent += char.trim()
isFirstChar = false
} else {
convertedContent += `<${charTimestamp}>${char.trim()}`
}
}
return `[${baseTimestamp}]${convertedContent}`
}
/**
* 转换旧格式:[mm:ss.xxx]字符(偏移,持续时间)
*/
function convertOldFormat(timestamp: string, content: string): string {
// 解析基础时间戳(毫秒)
const [minutes, seconds] = timestamp.split(':')
const [sec, ms] = seconds.split('.')
const baseTimeMs = parseInt(minutes) * 60 * 1000 + parseInt(sec) * 1000 + parseInt(ms)
let convertedContent = `<${timestamp}>`
// 匹配所有字符(偏移,持续时间)的模式
const charPattern = /([^()]+)\((\d+),(\d+)\)/g
let match
let lastIndex = 0
let isFirstChar = true
while ((match = charPattern.exec(content)) !== null) {
const [fullMatch, char, offsetMs, _durationMs] = match
const charTimeMs = baseTimeMs + parseInt(offsetMs)
const charTimestamp = formatTimestamp(charTimeMs)
// 添加匹配前的普通文本
if (match.index > lastIndex) {
const beforeText = content.substring(lastIndex, match.index)
if (beforeText.trim()) {
convertedContent += beforeText
}
}
// 添加带时间戳的字符
if (isFirstChar) {
// 第一个字符直接添加,不需要额外的时间戳
convertedContent += char
isFirstChar = false
} else {
convertedContent += `<${charTimestamp}>${char}`
}
lastIndex = match.index + fullMatch.length
}
// 添加剩余的普通文本
if (lastIndex < content.length) {
const remainingText = content.substring(lastIndex)
if (remainingText.trim()) {
convertedContent += remainingText
}
}
return `[${timestamp}]${convertedContent}`
}
// 写入音频标签的辅助函数
async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) {
try {
const tags: any = {}
// 写入基础信息
if (tagWriteOptions.basicInfo) {
tags.title = songInfo.name || ''
tags.artist = songInfo.singer || ''
tags.album = songInfo.albumName || ''
tags.year = songInfo.year || ''
tags.genre = songInfo.genre || ''
}
// 写入歌词
if (tagWriteOptions.lyrics && songInfo.lrc) {
// 转换LRC格式
const convertedLrc = convertLrcFormat(songInfo.lrc)
tags.unsynchronisedLyrics = {
language: 'chi',
shortText: 'Lyrics',
text: convertedLrc
}
}
// 写入封面
if (tagWriteOptions.cover && songInfo.img) {
try {
const coverResponse = await axios({
method: 'GET',
url: songInfo.img,
responseType: 'arraybuffer',
timeout: 10000
})
if (coverResponse.data) {
tags.image = {
mime: 'image/jpeg',
type: {
id: 3,
name: 'front cover'
},
description: 'Cover',
imageBuffer: Buffer.from(coverResponse.data)
}
}
} catch (coverError) {
console.warn('获取封面失败:', coverError)
}
}
// 写入标签到文件
if (Object.keys(tags).length > 0) {
const success = NodeID3.write(tags, filePath)
if (success) {
console.log('音频标签写入成功:', filePath)
} else {
console.warn('音频标签写入失败:', filePath)
}
}
} catch (error) {
console.error('写入音频标签时发生错误:', error)
throw error
}
}
function main(source: string) {
const Api = musicSdk[source]
return {
@@ -92,26 +303,20 @@ function main(source: string) {
return (await Api.songList.getListDetail(id, page)) as PlaylistDetailResult
},
async downloadSingleSong({ pluginId, songInfo, quality }: DownloadSingleSongArgs) {
async downloadSingleSong({
pluginId,
songInfo,
quality,
tagWriteOptions
}: DownloadSingleSongArgs) {
const url = await this.getMusicUrl({ pluginId, songInfo, quality })
if (typeof url === 'object') throw new Error('无法获取歌曲链接')
// 获取自定义下载目录
const getDownloadDirectory = (): string => {
try {
const configPath = path.join(app.getPath('userData'), CONFIG_NAME)
const configData = fs.readFileSync(configPath, 'utf-8')
const config = JSON.parse(configData)
if (config.downloadDir && typeof config.downloadDir === 'string') {
return config.downloadDir
}
} catch {
// 配置文件不存在或读取失败,使用默认目录
}
// 默认下载目录
return path.join(app.getPath('music'), 'CeruMusic/songs')
// 使用配置管理服务获取下载目录
const directories = configManager.getDirectories()
return directories.downloadDir
}
// 从URL中提取文件扩展名如果没有则默认为mp3
@@ -179,6 +384,16 @@ function main(source: string) {
delete fileLock[songPath]
}
// 写入标签信息
if (tagWriteOptions && fs.existsSync(songPath)) {
try {
await writeAudioTags(songPath, songInfo, tagWriteOptions)
} catch (error) {
console.warn('写入音频标签失败:', error)
// 标签写入失败不影响下载成功的返回
}
}
return {
message: '下载成功',
path: songPath

View File

@@ -90,6 +90,13 @@ export interface PlaylistDetailResult {
info: PlaylistInfo
}
export interface TagWriteOptions {
basicInfo?: boolean
cover?: boolean
lyrics?: boolean
}
export interface DownloadSingleSongArgs extends GetMusicUrlArg {
path?: string
tagWriteOptions?: TagWriteOptions
}

View File

@@ -1,10 +1,31 @@
import qrcDecrypt from './qrc-decrypt'
import { httpFetch } from '../../request'
import getMusicInfo from './musicInfo'
const songIdMap = new Map()
const promises = new Map()
const decode = qrcDecrypt()
export default {
rxps: {
info: /^{"/,
lineTime: /^\[(\d+),\d+\]/,
lineTime2: /^\[([\d:.]+)\]/,
wordTime: /\(\d+,\d+,\d+\)/,
wordTimeAll: /(\(\d+,\d+,\d+\))/g,
timeLabelFixRxp: /(?:\.0+|0+)$/
},
msFormat(timeMs) {
if (Number.isNaN(timeMs)) return ''
let ms = timeMs % 1000
timeMs /= 1000
let m = parseInt(timeMs / 60)
.toString()
.padStart(2, '0')
timeMs %= 60
let s = parseInt(timeMs).toString().padStart(2, '0')
return `[${m}:${s}.${String(ms).padStart(3, '0')}]`
},
successCode: 0,
async getSongId({ songId, songmid }) {
if (songId) return songId
@@ -17,6 +38,179 @@ export default {
promises.delete(songmid)
return info.songId
},
removeTag(str) {
return str.replace(/^[\S\s]*?LyricContent="/, '').replace(/"\/>[\S\s]*?$/, '')
},
parseCeru(lrc) {
lrc = lrc.trim()
lrc = lrc.replace(/\r/g, '')
if (!lrc) return { lyric: '', lxlyric: '' }
const lines = lrc.split('\n')
const lxlrcLines = []
const lrcLines = []
for (let line of lines) {
line = line.trim()
let result = this.rxps.lineTime.exec(line)
if (!result) {
if (line.startsWith('[offset')) {
lxlrcLines.push(line)
lrcLines.push(line)
continue
}
if (this.rxps.lineTime2.test(line)) {
// lxlrcLines.push(line)
lrcLines.push(line)
}
continue
}
const startMsTime = parseInt(result[1])
const startTimeStr = this.msFormat(startMsTime)
if (!startTimeStr) continue
let words = line.replace(this.rxps.lineTime, '')
lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`)
let times = words.match(this.rxps.wordTimeAll)
if (!times) continue
let currentStart = startMsTime
const processedTimes = []
times.forEach((time) => {
const result = /\((\d+),(\d+),(\d+)\)/.exec(time)
const duration = parseInt(result[2])
processedTimes.push(`(${currentStart},${duration},0)`)
currentStart += duration
})
const wordArr = words.split(this.rxps.wordTime)
const newWords = processedTimes.map((time, index) => `${time}${wordArr[index]}`).join('')
lxlrcLines.push(`${startTimeStr}${newWords}`)
}
return {
lyric: lrcLines.join('\n'),
lxlyric: lxlrcLines.join('\n')
}
},
getIntv(interval) {
if (!interval) return 0
if (!interval.includes('.')) interval += '.0'
let arr = interval.split(/:|\./)
while (arr.length < 3) arr.unshift('0')
const [m, s, ms] = arr
return parseInt(m) * 3600000 + parseInt(s) * 1000 + parseInt(ms)
},
fixRlrcTimeTag(rlrc, lrc) {
// console.log(lrc)
// console.log(rlrc)
const rlrcLines = rlrc.split('\n')
let lrcLines = lrc.split('\n')
// let temp = []
let newLrc = []
rlrcLines.forEach((line) => {
const result = this.rxps.lineTime2.exec(line)
if (!result) return
const words = line.replace(this.rxps.lineTime2, '')
if (!words.trim()) return
const t1 = this.getIntv(result[1])
while (lrcLines.length) {
const lrcLine = lrcLines.shift()
const lrcLineResult = this.rxps.lineTime2.exec(lrcLine)
if (!lrcLineResult) continue
const t2 = this.getIntv(lrcLineResult[1])
if (Math.abs(t1 - t2) < 100) {
newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0]))
break
}
// temp.push(line)
}
// lrcLines = [...temp, ...lrcLines]
// temp = []
})
return newLrc.join('\n')
},
fixTlrcTimeTag(tlrc, lrc) {
// console.log(lrc)
// console.log(tlrc)
const tlrcLines = tlrc.split('\n')
let lrcLines = lrc.split('\n')
// let temp = []
let newLrc = []
tlrcLines.forEach((line) => {
const result = this.rxps.lineTime2.exec(line)
if (!result) return
const words = line.replace(this.rxps.lineTime2, '')
if (!words.trim()) return
let time = result[1]
if (time.includes('.')) {
time += ''.padStart(3 - time.split('.')[1].length, '0')
}
const t1 = this.getIntv(time)
while (lrcLines.length) {
const lrcLine = lrcLines.shift()
const lrcLineResult = this.rxps.lineTime2.exec(lrcLine)
if (!lrcLineResult) continue
const t2 = this.getIntv(lrcLineResult[1])
if (Math.abs(t1 - t2) < 100) {
newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0]))
break
}
// temp.push(line)
}
// lrcLines = [...temp, ...lrcLines]
// temp = []
})
return newLrc.join('\n')
},
parse(lrc, tlrc, rlrc) {
const info = {
lyric: '',
tlyric: '',
rlyric: '',
crlyric: ''
}
if (lrc) {
let { lyric } = this.parseCeru(this.removeTag(lrc))
info.lyric = lyric
info.crlyric = lrc
}
if (rlrc) info.rlyric = this.fixRlrcTimeTag(this.parseRlyric(this.removeTag(rlrc)), info.lyric)
if (tlrc) info.tlyric = this.fixTlrcTimeTag(tlrc, info.lyric)
return info
},
parseRlyric(lrc) {
lrc = lrc.trim()
lrc = lrc.replace(/\r/g, '')
if (!lrc) return { lyric: '', lxlyric: '' }
const lines = lrc.split('\n')
const lrcLines = []
for (let line of lines) {
line = line.trim()
let result = this.rxps.lineTime.exec(line)
if (!result) continue
const startMsTime = parseInt(result[1])
const startTimeStr = this.msFormat(startMsTime)
if (!startTimeStr) continue
let words = line.replace(this.rxps.lineTime, '')
lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`)
}
return lrcLines.join('\n')
},
parseLyric(lrc, tlrc, rlrc) {
return this.parse(decode(lrc), decode(tlrc), decode(rlrc))
},
getLyric(mInfo, retryNum = 0) {
if (retryNum > 3) return Promise.reject(new Error('Get lyric failed'))

View File

@@ -0,0 +1,521 @@
import zlib from 'zlib'
export default () => {
const ENCRYPT = 1
const DECRYPT = 0
const sbox = [
// sbox1
[
14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12,
11, 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1,
7, 5, 11, 3, 14, 10, 0, 6, 13
],
// sbox2
[
15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 15, 12, 0, 1, 10,
6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2,
11, 6, 7, 12, 0, 5, 14, 9
],
// sbox3
[
10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14,
12, 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7,
4, 15, 14, 3, 11, 5, 2, 12
],
// sbox4
[
7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12,
1, 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 10, 13,
8, 9, 4, 5, 11, 12, 7, 2, 14
],
// sbox5
[
2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15,
10, 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2,
13, 6, 15, 0, 9, 10, 4, 5, 3
],
// sbox6
[
12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14,
0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10,
11, 14, 1, 7, 6, 0, 8, 13
],
// sbox7
[
4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12,
2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7,
9, 5, 0, 15, 14, 2, 3, 12
],
// sbox8
[
13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11,
0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13,
15, 12, 9, 0, 3, 5, 6, 11
]
]
/**
* 从 Buffer 中提取指定位置的位,并左移指定偏移量
* @param {Buffer} a - Buffer
* @param {number} b - 要提取的位索引
* @param {number} c - 位提取后的偏移量
* @returns {number} 提取后的位
*/
function bitnum(a, b, c) {
const byteIndex = Math.floor(b / 32) * 4 + 3 - Math.floor((b % 32) / 8)
const bitInByte = 7 - (b % 8)
const bit = (a[byteIndex] >> bitInByte) & 1
return bit << c
}
/**
* 从整数中提取指定位置的位,并左移指定偏移量
* @param {number} a - 整数
* @param {number} b - 要提取的位索引
* @param {number} c - 位提取后的偏移量
* @returns {number} 提取后的位
*/
function bitnum_intr(a, b, c) {
return (((a >>> (31 - b)) & 1) << c) | 0
}
/**
* 从整数中提取指定位置的位,并右移指定偏移量
* @param {number} a - 整数
* @param {number} b - 要提取的位索引
* @param {number} c - 位提取后的偏移量
* @returns {number} 提取后的位
*/
function bitnum_intl(a, b, c) {
return (((a << b) & 0x80000000) >>> c) | 0
}
/**
* 对输入整数进行位运算,重新组合位
* @param {number} a - 整数
* @returns {number} 重新组合后的位
*/
function sbox_bit(a) {
return (a & 32) | ((a & 31) >> 1) | ((a & 1) << 4) | 0
}
/**
* 初始置换
* @param {Buffer} input_data - 输入 Buffer
* @returns {[number, number]} 初始置换后的两个32位整数
*/
function initial_permutation(input_data) {
const s0 =
bitnum(input_data, 57, 31) |
bitnum(input_data, 49, 30) |
bitnum(input_data, 41, 29) |
bitnum(input_data, 33, 28) |
bitnum(input_data, 25, 27) |
bitnum(input_data, 17, 26) |
bitnum(input_data, 9, 25) |
bitnum(input_data, 1, 24) |
bitnum(input_data, 59, 23) |
bitnum(input_data, 51, 22) |
bitnum(input_data, 43, 21) |
bitnum(input_data, 35, 20) |
bitnum(input_data, 27, 19) |
bitnum(input_data, 19, 18) |
bitnum(input_data, 11, 17) |
bitnum(input_data, 3, 16) |
bitnum(input_data, 61, 15) |
bitnum(input_data, 53, 14) |
bitnum(input_data, 45, 13) |
bitnum(input_data, 37, 12) |
bitnum(input_data, 29, 11) |
bitnum(input_data, 21, 10) |
bitnum(input_data, 13, 9) |
bitnum(input_data, 5, 8) |
bitnum(input_data, 63, 7) |
bitnum(input_data, 55, 6) |
bitnum(input_data, 47, 5) |
bitnum(input_data, 39, 4) |
bitnum(input_data, 31, 3) |
bitnum(input_data, 23, 2) |
bitnum(input_data, 15, 1) |
bitnum(input_data, 7, 0) |
0
const s1 =
bitnum(input_data, 56, 31) |
bitnum(input_data, 48, 30) |
bitnum(input_data, 40, 29) |
bitnum(input_data, 32, 28) |
bitnum(input_data, 24, 27) |
bitnum(input_data, 16, 26) |
bitnum(input_data, 8, 25) |
bitnum(input_data, 0, 24) |
bitnum(input_data, 58, 23) |
bitnum(input_data, 50, 22) |
bitnum(input_data, 42, 21) |
bitnum(input_data, 34, 20) |
bitnum(input_data, 26, 19) |
bitnum(input_data, 18, 18) |
bitnum(input_data, 10, 17) |
bitnum(input_data, 2, 16) |
bitnum(input_data, 60, 15) |
bitnum(input_data, 52, 14) |
bitnum(input_data, 44, 13) |
bitnum(input_data, 36, 12) |
bitnum(input_data, 28, 11) |
bitnum(input_data, 20, 10) |
bitnum(input_data, 12, 9) |
bitnum(input_data, 4, 8) |
bitnum(input_data, 62, 7) |
bitnum(input_data, 54, 6) |
bitnum(input_data, 46, 5) |
bitnum(input_data, 38, 4) |
bitnum(input_data, 30, 3) |
bitnum(input_data, 22, 2) |
bitnum(input_data, 14, 1) |
bitnum(input_data, 6, 0) |
0
return [s0, s1]
}
/**
* 逆初始置换
* @param {number} s0 - 32位整数
* @param {number} s1 - 32位整数
* @returns {Buffer} 逆初始置换后的 Buffer
*/
function inverse_permutation(s0, s1) {
const data = Buffer.alloc(8)
data[3] =
bitnum_intr(s1, 7, 7) |
bitnum_intr(s0, 7, 6) |
bitnum_intr(s1, 15, 5) |
bitnum_intr(s0, 15, 4) |
bitnum_intr(s1, 23, 3) |
bitnum_intr(s0, 23, 2) |
bitnum_intr(s1, 31, 1) |
bitnum_intr(s0, 31, 0) |
0
data[2] =
bitnum_intr(s1, 6, 7) |
bitnum_intr(s0, 6, 6) |
bitnum_intr(s1, 14, 5) |
bitnum_intr(s0, 14, 4) |
bitnum_intr(s1, 22, 3) |
bitnum_intr(s0, 22, 2) |
bitnum_intr(s1, 30, 1) |
bitnum_intr(s0, 30, 0) |
0
data[1] =
bitnum_intr(s1, 5, 7) |
bitnum_intr(s0, 5, 6) |
bitnum_intr(s1, 13, 5) |
bitnum_intr(s0, 13, 4) |
bitnum_intr(s1, 21, 3) |
bitnum_intr(s0, 21, 2) |
bitnum_intr(s1, 29, 1) |
bitnum_intr(s0, 29, 0) |
0
data[0] =
bitnum_intr(s1, 4, 7) |
bitnum_intr(s0, 4, 6) |
bitnum_intr(s1, 12, 5) |
bitnum_intr(s0, 12, 4) |
bitnum_intr(s1, 20, 3) |
bitnum_intr(s0, 20, 2) |
bitnum_intr(s1, 28, 1) |
bitnum_intr(s0, 28, 0) |
0
data[7] =
bitnum_intr(s1, 3, 7) |
bitnum_intr(s0, 3, 6) |
bitnum_intr(s1, 11, 5) |
bitnum_intr(s0, 11, 4) |
bitnum_intr(s1, 19, 3) |
bitnum_intr(s0, 19, 2) |
bitnum_intr(s1, 27, 1) |
bitnum_intr(s0, 27, 0) |
0
data[6] =
bitnum_intr(s1, 2, 7) |
bitnum_intr(s0, 2, 6) |
bitnum_intr(s1, 10, 5) |
bitnum_intr(s0, 10, 4) |
bitnum_intr(s1, 18, 3) |
bitnum_intr(s0, 18, 2) |
bitnum_intr(s1, 26, 1) |
bitnum_intr(s0, 26, 0) |
0
data[5] =
bitnum_intr(s1, 1, 7) |
bitnum_intr(s0, 1, 6) |
bitnum_intr(s1, 9, 5) |
bitnum_intr(s0, 9, 4) |
bitnum_intr(s1, 17, 3) |
bitnum_intr(s0, 17, 2) |
bitnum_intr(s1, 25, 1) |
bitnum_intr(s0, 25, 0) |
0
data[4] =
bitnum_intr(s1, 0, 7) |
bitnum_intr(s0, 0, 6) |
bitnum_intr(s1, 8, 5) |
bitnum_intr(s0, 8, 4) |
bitnum_intr(s1, 16, 3) |
bitnum_intr(s0, 16, 2) |
bitnum_intr(s1, 24, 1) |
bitnum_intr(s0, 24, 0) |
0
return data
}
/**
* Triple-DES F函数
* @param {number} state - 输入
* @param {number[]} key - 密钥
* @returns {number} 输出
*/
function f(state, key) {
state = state | 0
const t1 =
bitnum_intl(state, 31, 0) |
(((state & 0xf0000000) >>> 1) | 0) |
bitnum_intl(state, 4, 5) |
bitnum_intl(state, 3, 6) |
(((state & 0x0f000000) >>> 3) | 0) |
bitnum_intl(state, 8, 11) |
bitnum_intl(state, 7, 12) |
(((state & 0x00f00000) >>> 5) | 0) |
bitnum_intl(state, 12, 17) |
bitnum_intl(state, 11, 18) |
(((state & 0x000f0000) >>> 7) | 0) |
bitnum_intl(state, 16, 23) |
0
const t2 =
bitnum_intl(state, 15, 0) |
(((state & 0x0000f000) << 15) | 0) |
bitnum_intl(state, 20, 5) |
bitnum_intl(state, 19, 6) |
(((state & 0x00000f00) << 13) | 0) |
bitnum_intl(state, 24, 11) |
bitnum_intl(state, 23, 12) |
(((state & 0x000000f0) << 11) | 0) |
bitnum_intl(state, 28, 17) |
bitnum_intl(state, 27, 18) |
(((state & 0x0000000f) << 9) | 0) |
bitnum_intl(state, 0, 23) |
0
const _lrgstate = [
(t1 >>> 24) & 0xff,
(t1 >>> 16) & 0xff,
(t1 >>> 8) & 0xff,
(t2 >>> 24) & 0xff,
(t2 >>> 16) & 0xff,
(t2 >>> 8) & 0xff
]
const lrgstate = _lrgstate.map((val, i) => val ^ key[i])
const newState =
(sbox[0][sbox_bit(lrgstate[0] >>> 2)] << 28) |
(sbox[1][sbox_bit(((lrgstate[0] & 0x03) << 4) | (lrgstate[1] >>> 4))] << 24) |
(sbox[2][sbox_bit(((lrgstate[1] & 0x0f) << 2) | (lrgstate[2] >>> 6))] << 20) |
(sbox[3][sbox_bit(lrgstate[2] & 0x3f)] << 16) |
(sbox[4][sbox_bit(lrgstate[3] >>> 2)] << 12) |
(sbox[5][sbox_bit(((lrgstate[3] & 0x03) << 4) | (lrgstate[4] >>> 4))] << 8) |
(sbox[6][sbox_bit(((lrgstate[4] & 0x0f) << 2) | (lrgstate[5] >>> 6))] << 4) |
sbox[7][sbox_bit(lrgstate[5] & 0x3f)] |
0
return (
bitnum_intl(newState, 15, 0) |
bitnum_intl(newState, 6, 1) |
bitnum_intl(newState, 19, 2) |
bitnum_intl(newState, 20, 3) |
bitnum_intl(newState, 28, 4) |
bitnum_intl(newState, 11, 5) |
bitnum_intl(newState, 27, 6) |
bitnum_intl(newState, 16, 7) |
bitnum_intl(newState, 0, 8) |
bitnum_intl(newState, 14, 9) |
bitnum_intl(newState, 22, 10) |
bitnum_intl(newState, 25, 11) |
bitnum_intl(newState, 4, 12) |
bitnum_intl(newState, 17, 13) |
bitnum_intl(newState, 30, 14) |
bitnum_intl(newState, 9, 15) |
bitnum_intl(newState, 1, 16) |
bitnum_intl(newState, 7, 17) |
bitnum_intl(newState, 23, 18) |
bitnum_intl(newState, 13, 19) |
bitnum_intl(newState, 31, 20) |
bitnum_intl(newState, 26, 21) |
bitnum_intl(newState, 2, 22) |
bitnum_intl(newState, 8, 23) |
bitnum_intl(newState, 18, 24) |
bitnum_intl(newState, 12, 25) |
bitnum_intl(newState, 29, 26) |
bitnum_intl(newState, 5, 27) |
bitnum_intl(newState, 21, 28) |
bitnum_intl(newState, 10, 29) |
bitnum_intl(newState, 3, 30) |
bitnum_intl(newState, 24, 31) |
0
)
}
/**
* TripleDES 加密/解密算法 (单块)
* @param {Buffer} input_data - 输入 Buffer
* @param {number[][]} key - 密钥
* @returns {Buffer} 加/解密后的 Buffer
*/
function crypt(input_data, key) {
let [s0, s1] = initial_permutation(input_data)
for (let idx = 0; idx < 15; idx++) {
const previous_s1 = s1
s1 = (f(s1, key[idx]) ^ s0) | 0
s0 = previous_s1
}
s0 = (f(s1, key[15]) ^ s0) | 0
return inverse_permutation(s0, s1)
}
/**
* TripleDES 密钥扩展算法
* @param {Buffer} key - 密钥
* @param {number} mode - 模式 (ENCRYPT/DECRYPT)
* @returns {number[][]} 密钥扩展
*/
function key_schedule(key, mode) {
const schedule = Array.from({ length: 16 }, () => Array(6).fill(0))
const key_rnd_shift = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]
const key_perm_c = [
56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59,
51, 43, 35
]
const key_perm_d = [
62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4,
27, 19, 11, 3
]
const key_compression = [
13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, 40, 51,
30, 36, 46, 54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, 28, 31
]
let c = 0,
d = 0
for (let i = 0; i < 28; i++) {
c |= bitnum(key, key_perm_c[i], 31 - i)
d |= bitnum(key, key_perm_d[i], 31 - i)
}
c = c | 0
d = d | 0
for (let i = 0; i < 16; i++) {
const shift = key_rnd_shift[i]
c = (((c << shift) | (c >>> (28 - shift))) & 0xfffffff0) | 0
d = (((d << shift) | (d >>> (28 - shift))) & 0xfffffff0) | 0
const togen = mode === DECRYPT ? 15 - i : i
schedule[togen] = Array(6).fill(0)
for (let j = 0; j < 24; j++) {
schedule[togen][Math.floor(j / 8)] |= bitnum_intr(c, key_compression[j], 7 - (j % 8))
}
for (let j = 24; j < 48; j++) {
schedule[togen][Math.floor(j / 8)] |= bitnum_intr(d, key_compression[j] - 27, 7 - (j % 8))
}
}
return schedule
}
/**
* TripleDES 密钥设置
* @param {Buffer} key - 密钥
* @param {number} mode - 模式
* @returns {number[][][]} 密钥设置
*/
function tripledes_key_setup(key, mode) {
if (mode === ENCRYPT) {
return [
key_schedule(key.slice(0, 8), ENCRYPT),
key_schedule(key.slice(8, 16), DECRYPT),
key_schedule(key.slice(16, 24), ENCRYPT)
]
}
return [
key_schedule(key.slice(16, 24), DECRYPT),
key_schedule(key.slice(8, 16), ENCRYPT),
key_schedule(key.slice(0, 8), DECRYPT)
]
}
/**
* TripleDES 加密/解密算法 (完整)
* @param {Buffer} data - 输入 Buffer
* @param {number[][][]} key - 密钥
* @returns {Buffer} 加/解密后的 Buffer
*/
function tripledes_crypt(data, key) {
let result = data
for (let i = 0; i < 3; i++) {
result = crypt(result, key[i])
}
return result
}
/**
* QRC解密主函数
* @param {string | Buffer} encrypted_qrc - 加密的QRC内容 (十六进制字符串或Buffer)
* @returns {string} 解密后的UTF-8字符串
*/
function qrc_decrypt(encrypted_qrc) {
if (!encrypted_qrc) {
return ''
}
let input_buffer
if (typeof encrypted_qrc === 'string') {
input_buffer = Buffer.from(encrypted_qrc, 'hex')
} else if (Buffer.isBuffer(encrypted_qrc)) {
input_buffer = encrypted_qrc
} else {
throw new Error('无效的加密数据类型')
}
try {
const decrypted_chunks = []
const key = Buffer.from('!@#)(*$%123ZXC!@!@#)(NHL')
const schedule = tripledes_key_setup(key, DECRYPT)
for (let i = 0; i < input_buffer.length; i += 8) {
const chunk = input_buffer.slice(i, i + 8)
if (chunk.length < 8) {
// 如果最后一块不足8字节DES无法处理但QRC格式应该是8的倍数
// 这里可以根据实际情况决定如何处理,例如抛出错误或填充
// 根据原始代码行为这里假设输入总是8字节的倍数
console.warn('警告: 数据末尾存在不足8字节的块可能导致解密不完整。')
continue
}
decrypted_chunks.push(tripledes_crypt(chunk, schedule))
}
const data = Buffer.concat(decrypted_chunks)
const decompressed = zlib.unzipSync(data)
return decompressed.toString('utf-8')
} catch (e) {
throw new Error(`解密失败: ${e.message}`)
}
}
// 导出主函数
return qrc_decrypt
}

View File

@@ -79,7 +79,7 @@ interface CustomAPI {
start: () => undefined
stop: () => undefined
}
// 目录设置API
directorySettings: {
getDirectories: () => Promise<{
@@ -96,10 +96,7 @@ interface CustomAPI {
path?: string
message?: string
}>
saveDirectories: (directories: {
cacheDir: string
downloadDir: string
}) => Promise<{
saveDirectories: (directories: { cacheDir: string; downloadDir: string }) => Promise<{
success: boolean
message: string
}>
@@ -119,15 +116,14 @@ interface CustomAPI {
size: number
formatted: string
}>
}
// 用户配置API
getUserConfig: () => Promise<any>
pluginNotice: {
onPluginNotice: (listener: (...args: any[]) => void) => ()=>void
}
onPluginNotice: (listener: (...args: any[]) => void) => () => void
}
}
declare global {

View File

@@ -10,6 +10,8 @@ declare module 'vue' {
export interface GlobalComponents {
AIFloatBallSettings: typeof import('./src/components/Settings/AIFloatBallSettings.vue')['default']
AudioVisualizer: typeof import('./src/components/Play/AudioVisualizer.vue')['default']
ContextMenu: typeof import('./src/components/ContextMenu/ContextMenu.vue')['default']
Demo: typeof import('./src/components/ContextMenu/demo.vue')['default']
DirectorySettings: typeof import('./src/components/Settings/DirectorySettings.vue')['default']
FloatBall: typeof import('./src/components/AI/FloatBall.vue')['default']
FullPlay: typeof import('./src/components/Play/FullPlay.vue')['default']
@@ -26,19 +28,29 @@ declare module 'vue' {
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default']
TAlert: typeof import('tdesign-vue-next')['Alert']
TAside: typeof import('tdesign-vue-next')['Aside']
TBadge: typeof import('tdesign-vue-next')['Badge']
TButton: typeof import('tdesign-vue-next')['Button']
TCard: typeof import('tdesign-vue-next')['Card']
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
TContent: typeof import('tdesign-vue-next')['Content']
TDialog: typeof import('tdesign-vue-next')['Dialog']
TDivider: typeof import('tdesign-vue-next')['Divider']
TDropdown: typeof import('tdesign-vue-next')['Dropdown']
TForm: typeof import('tdesign-vue-next')['Form']
TFormItem: typeof import('tdesign-vue-next')['FormItem']
ThemeSelector: typeof import('./src/components/ThemeSelector.vue')['default']
TIcon: typeof import('tdesign-vue-next')['Icon']
TImage: typeof import('tdesign-vue-next')['Image']
TInput: typeof import('tdesign-vue-next')['Input']
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
TLayout: typeof import('tdesign-vue-next')['Layout']
TLoading: typeof import('tdesign-vue-next')['Loading']
TRadioButton: typeof import('tdesign-vue-next')['RadioButton']
TRadioGroup: typeof import('tdesign-vue-next')['RadioGroup']
TSlider: typeof import('tdesign-vue-next')['Slider']
TSwitch: typeof import('tdesign-vue-next')['Switch']
TTag: typeof import('tdesign-vue-next')['Tag']
TTextarea: typeof import('tdesign-vue-next')['Textarea']
TTooltip: typeof import('tdesign-vue-next')['Tooltip']
UpdateExample: typeof import('./src/components/UpdateExample.vue')['default']

View File

@@ -77,6 +77,6 @@ body {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.t-dialog__mask{
.t-dialog__mask {
backdrop-filter: blur(5px);
}

View File

@@ -0,0 +1,811 @@
<template>
<Teleport v-if="visible" to="body">
<!-- 遮罩层 -->
<div
class="context-menu-backdrop"
@click="handleBackdropClick"
@contextmenu="handleBackdropContextMenu"
></div>
<!-- 右键菜单容器 -->
<div
ref="menuRef"
class="context-menu"
:class="[className, { 'context-menu--scrolling': isScrolling }]"
:style="menuStyle"
@mouseleave="handleMouseLeave"
@wheel="handleWheel"
>
<!-- 菜单项列表容器 -->
<div
ref="scrollContainer"
class="context-menu__scroll-container"
:style="scrollContainerStyle"
>
<!-- 菜单项列表 -->
<ul class="context-menu__list">
<li
v-for="item in visibleItems"
:key="item.id"
class="context-menu__item"
:class="[
{
'context-menu__item--disabled': item.disabled,
'context-menu__item--separator': item.separator,
'context-menu__item--has-children': item.children && item.children.length > 0
},
item.className
]"
@mouseenter="handleItemMouseEnter(item, $event)"
@mouseleave="handleItemMouseLeave(item)"
@click="handleItemClick(item, $event)"
>
<!-- 分隔线 -->
<div v-if="item.separator" class="context-menu__separator"></div>
<!-- 普通菜单项 -->
<template v-else>
<!-- 图标 -->
<div v-if="item.icon" class="context-menu__icon">
<component :is="item.icon" size="16" />
</div>
<!-- 标签 -->
<span class="context-menu__label">{{ item.label }}</span>
<!-- 子菜单箭头 -->
<div v-if="item.children && item.children.length > 0" class="context-menu__arrow">
<chevron-right-icon
:fill-color="'transparent'"
:stroke-color="'#000000'"
:stroke-width="1.5"
/>
</div>
</template>
</li>
</ul>
</div>
<!-- 滚动指示器 -->
<div v-if="showScrollIndicator" class="context-menu__scroll-indicator">
<div
class="context-menu__scroll-indicator-top"
:class="{ 'context-menu__scroll-indicator--visible': canScrollUp }"
></div>
<div
class="context-menu__scroll-indicator-bottom"
:class="{ 'context-menu__scroll-indicator--visible': canScrollDown }"
></div>
</div>
<!-- 子菜单 -->
<div
v-if="activeSubmenu"
class="context-menu__submenu-wrapper"
:style="submenuWrapperStyle"
@mouseenter="handleSubmenuMouseEnter"
@mouseleave="handleSubmenuMouseLeave"
>
<ContextMenu
ref="submenuRef"
:visible="true"
:position="submenuPosition"
:items="activeSubmenu.children || []"
:width="width"
:max-height="Math.min(maxHeight, 300)"
:z-index="zIndex + 1"
@item-click="handleSubmenuItemClick"
@close="closeSubmenu"
/>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted, type CSSProperties } from 'vue'
import type {
ContextMenuProps,
ContextMenuItem,
ContextMenuPosition,
EdgeDetectionConfig,
AnimationConfig,
ScrollConfig
} from './types'
import { ChevronRightIcon } from 'tdesign-icons-vue-next'
// 默认配置
const DEFAULT_EDGE_CONFIG: EdgeDetectionConfig = {
threshold: 10,
enabled: true
}
const DEFAULT_ANIMATION_CONFIG: AnimationConfig = {
duration: 200,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
enabled: true
}
const DEFAULT_SCROLL_CONFIG: ScrollConfig = {
scrollbarWidth: 6,
scrollSpeed: 40,
showScrollbar: true
}
// 组件属性
const props = withDefaults(defineProps<ContextMenuProps>(), {
visible: false,
position: () => ({ x: 0, y: 0 }),
items: () => [],
className: '',
width: 200,
maxHeight: 400,
zIndex: 1000
})
const emit = defineEmits<{
'update:visible': [value: boolean]
close: []
'item-click': [item: ContextMenuItem, event: MouseEvent]
}>()
// 响应式引用
const menuRef = ref<HTMLElement>()
const scrollContainer = ref<HTMLElement>()
const submenuRef = ref<any>()
// 状态管理
const isScrolling = ref(false)
const scrollTop = ref(0)
const scrollHeight = ref(0)
const clientHeight = ref(0)
const activeSubmenu = ref<ContextMenuItem | null>(null)
const submenuPosition = ref<ContextMenuPosition>({ x: 0, y: 0 })
const submenuTimer = ref<NodeJS.Timeout>()
const submenuMaxHeight = ref(300)
// 计算属性
const menuStyle = computed((): CSSProperties => {
const style: CSSProperties = {
'--menu-width': `${props.width}px`,
'--menu-max-height': `${props.maxHeight}px`,
'--menu-z-index': props.zIndex,
'--animation-duration': `${DEFAULT_ANIMATION_CONFIG.duration}ms`,
'--animation-easing': DEFAULT_ANIMATION_CONFIG.easing
}
if (!menuRef.value) {
return {
...style,
left: `${props.position.x}px`,
top: `${props.position.y}px`
}
}
const adjustedPosition = adjustMenuPosition(props.position)
return {
...style,
left: `${adjustedPosition.x}px`,
top: `${adjustedPosition.y}px`
}
})
const scrollContainerStyle = computed((): CSSProperties => {
return {
maxHeight: `${props.maxHeight}px`,
transform: `translateY(-${scrollTop.value}px)`
}
})
const visibleItems = computed(() => {
return props.items.filter((item) => {
// 显示所有非分隔线项目
if (!item.separator) return true
// 显示所有分隔线项目无论是否有label
return true
})
})
const showScrollIndicator = computed(() => {
return DEFAULT_SCROLL_CONFIG.showScrollbar && scrollHeight.value > clientHeight.value
})
const canScrollUp = computed(() => scrollTop.value > 0)
const canScrollDown = computed(() => scrollTop.value < scrollHeight.value - clientHeight.value)
const submenuWrapperStyle = computed((): CSSProperties => {
return {
position: 'fixed',
zIndex: props.zIndex + 1,
maxHeight: `${submenuMaxHeight.value}px`
}
})
// 监听器
watch(
() => props.visible,
(newVisible) => {
if (newVisible) {
nextTick(() => {
initializeScroll()
updateSubmenuPosition()
})
} else {
closeSubmenu()
resetScroll()
}
}
)
watch(
() => props.items,
() => {
if (props.visible) {
nextTick(initializeScroll)
}
}
)
// 生命周期
onMounted(() => {
document.addEventListener('keydown', handleKeyDown)
window.addEventListener('resize', handleWindowResize)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('resize', handleWindowResize)
clearTimeout(submenuTimer.value)
})
// 方法定义
const adjustMenuPosition = (position: ContextMenuPosition): ContextMenuPosition => {
if (!DEFAULT_EDGE_CONFIG.enabled || !menuRef.value) {
return position
}
const menuRect = menuRef.value.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const threshold = DEFAULT_EDGE_CONFIG.threshold
let adjustedX = position.x
let adjustedY = position.y
// 水平边缘检测
if (position.x + menuRect.width > viewportWidth - threshold) {
adjustedX = viewportWidth - menuRect.width - threshold
} else if (position.x < threshold) {
adjustedX = threshold
}
// 垂直边缘检测
if (position.y + menuRect.height > viewportHeight - threshold) {
adjustedY = viewportHeight - menuRect.height - threshold
} else if (position.y < threshold) {
adjustedY = threshold
}
return { x: adjustedX, y: adjustedY }
}
const initializeScroll = () => {
if (!scrollContainer.value) return
const container = scrollContainer.value
scrollHeight.value = container.scrollHeight
clientHeight.value = container.clientHeight
scrollTop.value = 0
}
const resetScroll = () => {
scrollTop.value = 0
scrollHeight.value = 0
clientHeight.value = 0
}
const scrollTo = (targetScrollTop: number) => {
const maxScrollTop = scrollHeight.value - clientHeight.value
scrollTop.value = Math.max(0, Math.min(targetScrollTop, maxScrollTop))
}
const scrollBy = (delta: number) => {
scrollTo(scrollTop.value + delta)
}
const handleWheel = (event: WheelEvent) => {
if (!showScrollIndicator.value) return
event.preventDefault()
event.stopPropagation()
const delta =
event.deltaY > 0 ? DEFAULT_SCROLL_CONFIG.scrollSpeed : -DEFAULT_SCROLL_CONFIG.scrollSpeed
scrollBy(delta)
isScrolling.value = true
clearTimeout(submenuTimer.value)
submenuTimer.value = setTimeout(() => {
isScrolling.value = false
}, 150)
}
const handleItemMouseEnter = (item: ContextMenuItem, event: MouseEvent) => {
if (item.disabled || item.separator) return
// 清除之前的子菜单定时器
clearTimeout(submenuTimer.value)
if (item.children && item.children.length > 0) {
submenuTimer.value = setTimeout(() => {
openSubmenu(item, event)
}, 200)
} else {
closeSubmenu()
}
}
const handleItemMouseLeave = (item: ContextMenuItem) => {
if (item.children && item.children.length > 0) {
clearTimeout(submenuTimer.value)
}
}
const handleItemClick = (item: ContextMenuItem, event: MouseEvent) => {
if (item.disabled || item.separator) return
// 调用菜单项的点击回调
if (item.onClick) {
item.onClick(item, event)
}
// 发射组件事件
emit('item-click', item, event)
// 如果没有子菜单,关闭菜单
if (!item.children || item.children.length === 0) {
closeMenu()
}
}
const handleSubmenuItemClick = (item: ContextMenuItem, event: MouseEvent) => {
emit('item-click', item, event)
closeMenu()
}
const openSubmenu = (item: ContextMenuItem, _event: MouseEvent) => {
if (!menuRef.value) return
// 如果是相同的子菜单,不需要重新计算位置
if (activeSubmenu.value && activeSubmenu.value.id === item.id) {
return
}
// 移除未使用的变量声明
activeSubmenu.value = item
nextTick(() => {
updateSubmenuPosition()
})
}
const closeSubmenu = () => {
activeSubmenu.value = null
clearTimeout(submenuTimer.value)
}
const updateSubmenuPosition = () => {
if (!menuRef.value || !activeSubmenu.value) return
const menuRect = menuRef.value.getBoundingClientRect()
// 初始位置:显示在右侧
const x = menuRect.right
const y = menuRect.top
// 先设置初始位置,让子菜单渲染
submenuPosition.value = { x, y }
// 等待子菜单渲染完成后调整位置
setTimeout(() => {
// 子菜单通过 Teleport 渲染到 body 中,需要在 body 中查找
// 查找所有的 context-menu 元素,找到 z-index 最高的(即子菜单)
const allMenus = document.querySelectorAll('.context-menu')
console.log('All menus found:', allMenus.length)
let submenuEl: Element | null = null
let maxZIndex = props.zIndex
allMenus.forEach((menu) => {
const style = window.getComputedStyle(menu)
const zIndex = parseInt(style.zIndex) || 0
console.log('Menu z-index:', zIndex, 'Current max:', maxZIndex)
if (zIndex > maxZIndex) {
maxZIndex = zIndex
submenuEl = menu as Element
}
})
console.log('Found submenu:', submenuEl)
if (submenuEl) {
const submenuRect = (submenuEl as HTMLElement).getBoundingClientRect()
console.log('submenuRect:', submenuRect)
if (submenuRect.width > 0) {
// 计算包含滚动条的实际宽度
const scrollContainer = (submenuEl as HTMLElement).querySelector(
'.context-menu__scroll-container'
) as HTMLElement | null
let actualWidth = submenuRect.width
if (scrollContainer) {
// 检查是否有滚动条
const hasScrollbar = scrollContainer.scrollHeight > scrollContainer.clientHeight
if (hasScrollbar) {
// 添加滚动条宽度通常是6-17px这里使用默认的6px
const scrollbarWidth = scrollContainer.offsetWidth - scrollContainer.clientWidth
actualWidth += scrollbarWidth
console.log('Added scrollbar width:', scrollbarWidth, 'Total width:', actualWidth)
}
}
adjustSubmenuPosition(actualWidth)
} else {
// 如果宽度为0再等一下
setTimeout(() => {
const retryRect = (submenuEl as HTMLElement).getBoundingClientRect()
console.log('retryRect:', retryRect)
if (retryRect.width > 0) {
// 重试时也要考虑滚动条
const scrollContainer = (submenuEl as HTMLElement).querySelector(
'.context-menu__scroll-container'
) as HTMLElement | null
let actualWidth = retryRect.width
if (scrollContainer) {
const hasScrollbar = scrollContainer.scrollHeight > scrollContainer.clientHeight
if (hasScrollbar) {
const scrollbarWidth = scrollContainer.offsetWidth - scrollContainer.clientWidth
actualWidth += scrollbarWidth
}
}
adjustSubmenuPosition(actualWidth)
}
}, 50)
}
}
}, 0)
}
// 提取位置调整逻辑为独立函数
const adjustSubmenuPosition = (submenuWidth: number) => {
if (!menuRef.value || !activeSubmenu.value) return
const menuRect = menuRef.value.getBoundingClientRect()
const viewportWidth = window.innerWidth
const threshold = 10
// 重新计算位置
let adjustedX = menuRect.right
const y = menuRect.top
// 检查右侧是否有足够空间显示子菜单
if (adjustedX + submenuWidth > viewportWidth - threshold) {
// 如果右侧空间不足显示在左侧父元素的left - 子菜单宽度
adjustedX = menuRect.left - submenuWidth
}
// 确保子菜单不会超出左边界
if (adjustedX < threshold) {
adjustedX = threshold
}
console.log('Final position:', { x: adjustedX, y })
// 更新最终位置
submenuPosition.value = { x: adjustedX, y }
}
const handleBackdropClick = () => {
closeMenu()
}
const handleBackdropContextMenu = (event: MouseEvent) => {
event.preventDefault()
closeMenu()
}
const handleMouseLeave = () => {
clearTimeout(submenuTimer.value)
}
const handleSubmenuMouseEnter = () => {
// 鼠标进入子菜单区域,清除关闭定时器
clearTimeout(submenuTimer.value)
}
const handleSubmenuMouseLeave = () => {
// 鼠标离开子菜单区域,延迟关闭子菜单
submenuTimer.value = setTimeout(() => {
closeSubmenu()
}, 100)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (!props.visible) return
switch (event.key) {
case 'Escape':
event.preventDefault()
closeMenu()
break
case 'ArrowUp':
event.preventDefault()
scrollBy(-DEFAULT_SCROLL_CONFIG.scrollSpeed)
break
case 'ArrowDown':
event.preventDefault()
scrollBy(DEFAULT_SCROLL_CONFIG.scrollSpeed)
break
}
}
const handleWindowResize = () => {
if (props.visible) {
closeMenu()
}
}
const closeMenu = () => {
emit('update:visible', false)
emit('close')
}
// 暴露给父组件的方法
defineExpose({
updatePosition: (_position: ContextMenuPosition) => {
// 位置更新逻辑
},
updateItems: (_items: ContextMenuItem[]) => {
// 菜单项更新逻辑
},
hide: closeMenu
})
</script>
<style scoped>
.context-menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: calc(var(--menu-z-index) - 1);
background: transparent;
}
.context-menu {
position: fixed;
min-width: var(--menu-width);
max-width: 300px;
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
z-index: var(--menu-z-index);
overflow: auto;
animation: contextMenuEnter var(--animation-duration) var(--animation-easing);
}
.context-menu--scrolling {
pointer-events: auto;
}
.context-menu__scroll-container {
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
/* scrollbar-color: rgba(255, 255, 255, 0.3) transparent; */
transition: transform 0.15s ease;
}
.context-menu__scroll-container::-webkit-scrollbar {
width: 6px;
}
/*
.context-menu__scroll-container::-webkit-scrollbar-track {
background: transparent;
}
.context-menu__scroll-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
.context-menu__scroll-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
} */
.context-menu__list {
list-style: none;
margin: 0;
padding: 4px 0;
min-width: 100%;
}
.context-menu__item {
position: relative;
display: flex;
align-items: center;
padding: 8px 12px;
margin: 0 4px;
border-radius: 4px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
min-height: 32px;
box-sizing: border-box;
}
.context-menu__item:hover:not(.context-menu__item--disabled):not(.context-menu__item--separator) {
background: #f5f5f5;
}
.context-menu__item--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.context-menu__item--separator {
padding: 0;
margin: 4px 0;
cursor: default;
height: auto;
min-height: auto;
}
.context-menu__item--has-children {
padding-right: 24px;
}
.context-menu__icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 8px;
color: #666;
flex-shrink: 0;
}
.context-menu__label {
flex: 1;
font-size: 13px;
color: #333;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-menu__arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
justify-content: center;
align-items: center;
display: flex;
color: #999;
}
.context-menu__arrow-icon {
font-size: 10px;
font-style: normal;
}
.context-menu__separator {
height: 1px;
width: 100%;
background: #e0e0e0;
margin: 0 8px;
opacity: 0.8;
}
.context-menu__scroll-indicator {
position: absolute;
right: 2px;
top: 4px;
bottom: 4px;
width: var(--scrollbar-width, 6px);
pointer-events: none;
}
.context-menu__scroll-indicator-top,
.context-menu__scroll-indicator-bottom {
position: absolute;
left: 0;
width: 100%;
height: 20px;
opacity: 0;
transition: opacity 0.2s ease;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.9), transparent);
}
.context-menu__scroll-indicator-top {
top: 0;
transform: rotate(180deg);
}
.context-menu__scroll-indicator-bottom {
bottom: 0;
}
.context-menu__scroll-indicator--visible {
opacity: 1;
}
/* 动画 */
@keyframes contextMenuEnter {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.context-menu {
min-width: 180px;
max-width: 280px;
border-radius: 6px;
}
.context-menu__item {
padding: 10px 12px;
min-height: 36px;
}
.context-menu__label {
font-size: 14px;
}
}
/* 暗色主题支持 */
@media (prefers-color-scheme: dark) {
.context-menu {
background: #2d2d2d;
border-color: #404040;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.context-menu__item:hover:not(.context-menu__item--disabled):not(.context-menu__item--separator) {
background: #404040;
}
.context-menu__icon {
color: #ccc;
}
.context-menu__label {
color: #e0e0e0;
}
.context-menu__separator {
background: #555555;
opacity: 0.9;
}
.context-menu__scroll-indicator-top,
.context-menu__scroll-indicator-bottom {
background: linear-gradient(to bottom, rgba(45, 45, 45, 0.9), transparent);
}
}
</style>

View File

@@ -0,0 +1,244 @@
# 自定义右键菜单组件
一个功能完整、可扩展的自定义右键菜单组件,专为歌曲列表等场景设计。
## 特性
-**精确的边缘点击判定** - 智能计算位置,确保菜单始终在可视区域内
-**滚动支持** - 支持菜单项过多时的滚动选择
-**可扩展性** - 易于添加新的菜单项和功能
-**平滑动画** - 流畅的显示/隐藏动画效果
-**自适应显示** - 在不同屏幕尺寸下自动适配
-**完整TypeScript支持** - 提供完整的类型定义
## 安装和使用
### 基本使用
```vue
<template>
<div @contextmenu.prevent="handleContextMenu">
<!-- 你的内容 -->
</div>
<ContextMenu
v-model:visible="menuVisible"
:items="menuItems"
:position="menuPosition"
@item-click="handleMenuItemClick"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ContextMenu from './ContextMenu/ContextMenu.vue'
import { createMenuItem, createSeparator } from './ContextMenu/utils'
import type { ContextMenuItem } from './ContextMenu/types'
const menuVisible = ref(false)
const menuPosition = ref({ x: 0, y: 0 })
const menuItems = ref<ContextMenuItem[]>([
createMenuItem('play', '播放', {
onClick: (item, event) => console.log('播放点击')
}),
createSeparator(),
createMenuItem('download', '下载')
])
</script>
```
### 在歌曲列表中使用
```vue
<template>
<div class="song-list">
<div
v-for="song in songs"
:key="song.id"
class="song-item"
@contextmenu.prevent="handleSongContextMenu(song, $event)"
>
{{ song.name }}
</div>
</div>
<ContextMenu
v-model:visible="contextMenuVisible"
:items="contextMenuItems"
:position="contextMenuPosition"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ContextMenu from './ContextMenu/ContextMenu.vue'
import { createMenuItem, createSeparator } from './ContextMenu/utils'
const contextMenuVisible = ref(false)
const contextMenuPosition = ref({ x: 0, y: 0 })
const currentSong = ref(null)
const contextMenuItems = computed(() => [
createMenuItem('play', '播放', {
onClick: () => playSong(currentSong.value)
}),
createMenuItem('addToPlaylist', '添加到播放列表'),
createSeparator(),
createMenuItem('download', '下载')
])
const handleSongContextMenu = (song, event) => {
currentSong.value = song
contextMenuPosition.value = { x: event.clientX, y: event.clientY }
contextMenuVisible.value = true
}
</script>
```
## API 文档
### ContextMenu 组件属性
| 属性 | 类型 | 默认值 | 说明 |
| --------- | ------------------- | --------- | ----------------- |
| visible | boolean | false | 控制菜单显示/隐藏 |
| items | ContextMenuItem[] | [] | 菜单项配置数组 |
| position | ContextMenuPosition | {x:0,y:0} | 菜单位置坐标 |
| maxHeight | number | 400 | 菜单最大高度 |
| zIndex | number | 1000 | 菜单层级 |
### ContextMenuItem 类型
```typescript
interface ContextMenuItem {
id: string
label: string
icon?: any
disabled?: boolean
separator?: boolean
children?: ContextMenuItem[]
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
className?: string
}
```
### 工具函数
#### createMenuItem
创建标准菜单项
```typescript
createMenuItem(id: string, label: string, options?: {
icon?: any
disabled?: boolean
separator?: boolean
children?: ContextMenuItem[]
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
className?: string
}): ContextMenuItem
```
#### createSeparator
创建分隔线
```typescript
createSeparator(): ContextMenuItem
```
#### calculateMenuPosition
智能计算菜单位置
```typescript
calculateMenuPosition(
event: MouseEvent,
menuWidth?: number,
menuHeight?: number
): ContextMenuPosition
```
## 高级用法
### 子菜单支持
```typescript
const menuItems = [
createMenuItem('playlist', '添加到歌单', {
children: [
createMenuItem('playlist1', '我的最爱'),
createMenuItem('playlist2', '开车音乐'),
createSeparator(),
createMenuItem('newPlaylist', '新建歌单')
]
})
]
```
### 动态菜单项
```typescript
const dynamicMenuItems = computed(() => {
const items = [createMenuItem('play', '播放')]
if (user.value.isPremium) {
items.push(createMenuItem('download', '下载高音质'))
}
return items
})
```
### 自定义样式
```typescript
const menuItems = [
createMenuItem('danger', '删除歌曲', {
className: 'danger-item'
})
]
```
```css
.danger-item {
color: #ff4d4f;
}
.danger-item:hover {
background-color: #fff2f0;
}
```
## 最佳实践
1. **使用防抖处理频繁的右键事件**
2. **合理设置菜单最大高度,避免过长滚动**
3. **为重要操作添加确认对话框**
4. **根据用户权限动态显示菜单项**
5. **在移动端考虑触摸替代方案**
## 浏览器兼容性
- Chrome 60+
- Firefox 55+
- Safari 12+
- Edge 79+
## 故障排除
### 菜单位置不正确
确保使用 `calculateMenuPosition` 函数计算位置。
### 菜单项点击无效
检查 `onClick` 回调函数是否正确绑定。
### 样式冲突
使用 `className` 属性添加自定义样式类。
## 贡献指南
欢迎提交 Issue 和 Pull Request 来改进这个组件。

View File

@@ -0,0 +1,397 @@
import { ref, computed, type Ref } from 'vue'
import type { ContextMenuItem, ContextMenuPosition } from './types'
import { createMenuItem, createSeparator } from './utils'
/**
* 右键菜单组合式函数
*/
export function useContextMenu() {
const visible = ref(false)
const position = ref<ContextMenuPosition>({ x: 0, y: 0 })
const items = ref<ContextMenuItem[]>([])
const currentData = ref<any>(null)
/**
* 显示菜单
*/
const show = (event: MouseEvent, menuItems: ContextMenuItem[], data?: any) => {
event.preventDefault()
event.stopPropagation()
position.value = {
x: event.clientX,
y: event.clientY
}
items.value = menuItems
currentData.value = data
visible.value = true
}
/**
* 隐藏菜单
*/
const hide = () => {
visible.value = false
currentData.value = null
}
/**
* 更新菜单位置
*/
const updatePosition = (newPosition: ContextMenuPosition) => {
position.value = newPosition
}
/**
* 更新菜单项
*/
const updateItems = (newItems: ContextMenuItem[]) => {
items.value = newItems
}
/**
* 处理菜单项点击
*/
const handleItemClick = (item: ContextMenuItem, event: MouseEvent) => {
if (item.onClick) {
item.onClick(item, event)
}
hide()
}
return {
// 状态
visible: computed(() => visible.value),
position: computed(() => position.value),
items: computed(() => items.value),
currentData: computed(() => currentData.value),
// 方法
show,
hide,
updatePosition,
updateItems,
handleItemClick
}
}
/**
* 歌曲相关的右键菜单配置
*/
export function useSongContextMenu() {
const { show, hide, ...rest } = useContextMenu()
/**
* 显示歌曲右键菜单
*/
const showSongMenu = (
event: MouseEvent,
song: any,
options?: {
showPlay?: boolean
showAddToPlaylist?: boolean
showDownload?: boolean
showAddToSongList?: boolean
playlists?: any[]
onPlay?: (song: any) => void
onAddToPlaylist?: (song: any) => void
onDownload?: (song: any) => void
onAddToSongList?: (song: any, playlist: any) => void
}
) => {
const {
showPlay = true,
showAddToPlaylist = true,
showDownload = true,
showAddToSongList = true,
playlists = [],
onPlay,
onAddToPlaylist,
onDownload,
onAddToSongList
} = options || {}
const menuItems: ContextMenuItem[] = []
// 播放
if (showPlay) {
menuItems.push(
createMenuItem('play', '播放', {
onClick: () => onPlay?.(song)
})
)
}
// 添加到播放列表
if (showAddToPlaylist) {
menuItems.push(
createMenuItem('addToPlaylist', '添加到播放列表', {
onClick: () => onAddToPlaylist?.(song)
})
)
}
// 添加到歌单(如果有歌单)
if (showAddToSongList && playlists.length > 0) {
menuItems.push(
createMenuItem('addToSongList', '加入歌单', {
children: playlists.map((playlist) =>
createMenuItem(`playlist_${playlist.id}`, playlist.name, {
onClick: () => onAddToSongList?.(song, playlist)
})
)
})
)
}
// 分隔线
if (menuItems.length > 0) {
menuItems.push(createSeparator())
}
// 下载
if (showDownload) {
menuItems.push(
createMenuItem('download', '下载', {
onClick: () => onDownload?.(song)
})
)
}
show(event, menuItems, song)
}
return {
...rest,
showSongMenu,
hide
}
}
/**
* 列表项右键菜单配置
*/
export function useListItemContextMenu() {
const { show, hide, ...rest } = useContextMenu()
/**
* 显示列表项右键菜单
*/
const showListItemMenu = (
event: MouseEvent,
item: any,
options?: {
showEdit?: boolean
showDelete?: boolean
showCopy?: boolean
showProperties?: boolean
onEdit?: (item: any) => void
onDelete?: (item: any) => void
onCopy?: (item: any) => void
onProperties?: (item: any) => void
}
) => {
const {
showEdit = true,
showDelete = true,
showCopy = false,
showProperties = false,
onEdit,
onDelete,
onCopy,
onProperties
} = options || {}
const menuItems: ContextMenuItem[] = []
// 编辑
if (showEdit) {
menuItems.push(
createMenuItem('edit', '编辑', {
onClick: () => onEdit?.(item)
})
)
}
// 复制
if (showCopy) {
menuItems.push(
createMenuItem('copy', '复制', {
onClick: () => onCopy?.(item)
})
)
}
// 分隔线
if (menuItems.length > 0 && (showDelete || showProperties)) {
menuItems.push(createSeparator())
}
// 删除
if (showDelete) {
menuItems.push(
createMenuItem('delete', '删除', {
onClick: () => onDelete?.(item)
})
)
}
// 属性
if (showProperties) {
menuItems.push(
createMenuItem('properties', '属性', {
onClick: () => onProperties?.(item)
})
)
}
show(event, menuItems, item)
}
return {
...rest,
showListItemMenu,
hide
}
}
/**
* 文本选择右键菜单配置
*/
export function useTextSelectionContextMenu() {
const { show, hide, ...rest } = useContextMenu()
/**
* 显示文本选择右键菜单
*/
const showTextSelectionMenu = (
event: MouseEvent,
selectedText: string,
options?: {
showCopy?: boolean
showSearch?: boolean
showTranslate?: boolean
onCopy?: (text: string) => void
onSearch?: (text: string) => void
onTranslate?: (text: string) => void
}
) => {
const {
showCopy = true,
showSearch = true,
showTranslate = false,
onCopy,
onSearch,
onTranslate
} = options || {}
const menuItems: ContextMenuItem[] = []
// 复制
if (showCopy) {
menuItems.push(
createMenuItem('copy', '复制', {
onClick: () => onCopy?.(selectedText)
})
)
}
// 搜索
if (showSearch) {
menuItems.push(
createMenuItem('search', '搜索', {
onClick: () => onSearch?.(selectedText)
})
)
}
// 翻译
if (showTranslate) {
menuItems.push(
createMenuItem('translate', '翻译', {
onClick: () => onTranslate?.(selectedText)
})
)
}
show(event, menuItems, selectedText)
}
return {
...rest,
showTextSelectionMenu,
hide
}
}
/**
* 创建可复用的菜单配置
*/
export function createMenuConfig<T = any>(config: {
items: ContextMenuItem[]
onItemClick?: (item: ContextMenuItem, data: T, event: MouseEvent) => void
onShow?: (data: T) => void
onHide?: () => void
}) {
const { items, onItemClick, onShow, onHide } = config
return {
items: ref([...items]),
show: (_event: MouseEvent, data: T) => {
onShow?.(data)
},
handleItemClick: (item: ContextMenuItem, event: MouseEvent, data: T) => {
onItemClick?.(item, data, event)
},
hide: () => {
onHide?.()
}
}
}
/**
* 菜单项可见性控制
*/
export function useMenuVisibility<T extends ContextMenuItem>(
items: Ref<T[]>,
predicate: (item: T) => boolean
) {
const visibleItems = computed(() => items.value.filter(predicate))
const hasVisibleItems = computed(() => visibleItems.value.length > 0)
return {
visibleItems,
hasVisibleItems
}
}
/**
* 菜单项动态启用/禁用控制
*/
export function useMenuItemsState<T extends ContextMenuItem>(
items: Ref<T[]>,
getState: (item: T) => { disabled?: boolean; visible?: boolean }
) {
const processedItems = computed(() =>
items.value
.map((item) => {
const state = getState(item)
return {
...item,
disabled: state.disabled ?? item.disabled,
// 如果visible为false完全移除该项
...(state.visible === false ? { _hidden: true } : {})
}
})
.filter((item) => !(item as any)._hidden)
)
return {
processedItems
}
}

View File

@@ -0,0 +1,199 @@
<template>
<div class="demo-container">
<h1>右键菜单组件演示</h1>
<!-- 测试区域 -->
<div class="test-area">
<div
class="test-box"
style="width: 300px; height: 200px; border: 2px dashed #ccc; padding: 20px"
@contextmenu.prevent="handleContextMenu($event)"
>
<p>在此区域右键点击测试菜单</p>
<p>菜单项数量{{ menuItems.length }}</p>
</div>
</div>
<!-- 右键菜单 -->
<ContextMenu
v-model:visible="menuVisible"
:position="menuPosition"
:items="menuItems"
:max-height="200"
@item-click="handleMenuItemClick"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ContextMenu from './ContextMenu.vue'
import type { ContextMenuItem } from './types'
const menuVisible = ref(false)
const menuPosition = ref({ x: 0, y: 0 })
// 创建大量菜单项用于测试滚动
const menuItems = ref<ContextMenuItem[]>([
{
id: 'play',
label: '播放',
icon: '▶'
},
{
id: 'pause',
label: '暂停',
icon: '⏸'
},
{
id: 'separator-1',
separator: true
},
{
id: 'add-to-playlist',
label: '添加到播放列表',
icon: ''
},
{
id: 'remove-from-playlist',
label: '从播放列表移除',
icon: ''
},
{
id: 'separator-2',
separator: true
},
{
id: 'download',
label: '下载歌曲',
icon: '⬇️'
},
{
id: 'share',
label: '分享',
icon: '↗️'
},
{
id: 'separator-3',
separator: true
},
{
id: 'info',
label: '歌曲信息',
icon: ''
},
{
id: 'edit-tags',
label: '编辑标签',
icon: '✏️'
},
{
id: 'separator-4',
separator: true
},
{
id: 'rate-1',
label: '评分:★☆☆☆☆',
icon: '⭐'
},
{
id: 'rate-2',
label: '评分:★★☆☆☆',
icon: '⭐'
},
{
id: 'rate-3',
label: '评分:★★★☆☆',
icon: '⭐'
},
{
id: 'rate-4',
label: '评分:★★★★☆',
icon: '⭐'
},
{
id: 'rate-5',
label: '评分:★★★★★',
icon: '⭐'
},
{
id: 'separator-5',
separator: true
},
{
id: 'create-station',
label: '创建电台',
icon: '📻'
},
{
id: 'similar-songs',
label: '相似歌曲',
icon: '🎵'
},
{
id: 'separator-6',
separator: true
},
{
id: 'copy-link',
label: '复制链接',
icon: '🔗'
},
{
id: 'properties',
label: '属性',
icon: '📋'
},
{
id: 'separator-7',
separator: true
},
{
id: 'delete',
label: '删除',
icon: '🗑️',
className: 'danger'
}
])
const handleContextMenu = (event: MouseEvent) => {
menuPosition.value = {
x: event.clientX,
y: event.clientY
}
menuVisible.value = true
}
const handleMenuItemClick = (item: ContextMenuItem) => {
console.log('菜单项点击:', item.label)
// 这里可以添加具体的菜单项处理逻辑
}
</script>
<style scoped>
.demo-container {
padding: 20px;
font-family: Arial, sans-serif;
}
.test-area {
margin: 20px 0;
}
.test-box {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
transition: background-color 0.2s;
}
.test-box:hover {
background-color: #f5f5f5;
}
.danger {
color: #ff4444 !important;
}
</style>

View File

@@ -0,0 +1,4 @@
export { default as ContextMenu } from './ContextMenu.vue'
export * from './types'
export * from './utils'
export * from './composables'

View File

@@ -0,0 +1,101 @@
/**
* 右键菜单位置类型定义
*/
export interface ContextMenuPosition {
x: number
y: number
}
/**
* 右键菜单项类型定义
*/
export interface ContextMenuItem {
/** 菜单项唯一标识 */
id: string
/** 显示文本 */
label?: string
/** 图标组件 */
icon?: any
/** 是否禁用 */
disabled?: boolean
/** 是否显示分隔线 */
separator?: boolean
/** 子菜单项 */
children?: ContextMenuItem[]
/** 点击回调函数 */
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
/** 自定义CSS类名 */
className?: string
}
/**
* 右键菜单配置属性
*/
export interface ContextMenuProps {
/** 是否显示菜单 */
visible: boolean
/** 菜单位置 */
position: ContextMenuPosition
/** 菜单项列表 */
items: ContextMenuItem[]
/** 自定义CSS类名 */
className?: string
/** 菜单宽度 */
width?: number
/** 最大高度(超出时显示滚动条) */
maxHeight?: number
/** 菜单层级 */
zIndex?: number
/** 关闭菜单回调 */
onClose?: () => void
/** 菜单项点击回调 */
onItemClick?: (item: ContextMenuItem, event: MouseEvent) => void
}
/**
* 边缘检测配置
*/
export interface EdgeDetectionConfig {
/** 距离边缘的阈值(像素) */
threshold: number
/** 是否启用边缘检测 */
enabled: boolean
}
/**
* 动画配置
*/
export interface AnimationConfig {
/** 动画持续时间(毫秒) */
duration: number
/** 动画缓动函数 */
easing: string
/** 是否启用动画 */
enabled: boolean
}
/**
* 滚动配置
*/
export interface ScrollConfig {
/** 滚动条宽度 */
scrollbarWidth: number
/** 滚动速度 */
scrollSpeed: number
/** 是否显示滚动条 */
showScrollbar: boolean
}
/**
* 右键菜单实例方法
*/
export interface ContextMenuInstance {
/** 显示菜单 */
show: (position: ContextMenuPosition, items?: ContextMenuItem[]) => void
/** 隐藏菜单 */
hide: () => void
/** 更新菜单位置 */
updatePosition: (position: ContextMenuPosition) => void
/** 更新菜单项 */
updateItems: (items: ContextMenuItem[]) => void
}

View File

@@ -0,0 +1,266 @@
import type { ContextMenuItem, ContextMenuPosition } from './types'
/**
* 创建标准菜单项
*/
export function createMenuItem(
id: string,
label: string,
options?: {
icon?: any
disabled?: boolean
separator?: boolean
children?: ContextMenuItem[]
onClick?: (item: ContextMenuItem, event: MouseEvent) => void
className?: string
}
): ContextMenuItem {
return {
id,
label,
icon: options?.icon,
disabled: options?.disabled || false,
separator: options?.separator || false,
children: options?.children,
onClick: options?.onClick,
className: options?.className
}
}
/**
* 创建分隔线菜单项
*/
export function createSeparator(): ContextMenuItem {
return {
id: `separator-${Date.now()}`,
label: '',
separator: true
}
}
/**
* 计算菜单位置,确保在可视区域内
*/
export function calculateMenuPosition(
event: MouseEvent,
menuWidth: number = 200,
menuHeight: number = 400
): ContextMenuPosition {
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const threshold = 10
let x = event.clientX
let y = event.clientY
// 水平边缘检测
if (x + menuWidth > viewportWidth - threshold) {
x = viewportWidth - menuWidth - threshold
} else if (x < threshold) {
x = threshold
}
// 垂直边缘检测
if (y + menuHeight > viewportHeight - threshold) {
y = viewportHeight - menuHeight - threshold
} else if (y < threshold) {
y = threshold
}
return { x, y }
}
/**
* 防抖函数
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout
return (...args: Parameters<T>) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => func(...args), delay)
}
}
/**
* 节流函数
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args)
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
}
}
}
/**
* 深度克隆菜单项(避免引用问题)
*/
export function cloneMenuItem(item: ContextMenuItem): ContextMenuItem {
return {
...item,
children: item.children ? item.children.map(cloneMenuItem) : undefined
}
}
/**
* 扁平化菜单项(用于搜索等功能)
*/
export function flattenMenuItems(items: ContextMenuItem[]): ContextMenuItem[] {
const result: ContextMenuItem[] = []
items.forEach((item) => {
result.push(item)
if (item.children && item.children.length > 0) {
result.push(...flattenMenuItems(item.children))
}
})
return result
}
/**
* 根据ID查找菜单项
*/
export function findMenuItemById(items: ContextMenuItem[], id: string): ContextMenuItem | null {
for (const item of items) {
if (item.id === id) {
return item
}
if (item.children && item.children.length > 0) {
const found = findMenuItemById(item.children, id)
if (found) {
return found
}
}
}
return null
}
/**
* 验证菜单项配置
*/
export function validateMenuItem(item: ContextMenuItem): boolean {
if (!item.id || typeof item.id !== 'string') {
console.warn('菜单项必须包含有效的id字段')
return false
}
if (!item.separator && (!item.label || typeof item.label !== 'string')) {
console.warn('非分隔线菜单项必须包含有效的label字段')
return false
}
if (item.children && !Array.isArray(item.children)) {
console.warn('children字段必须是数组')
return false
}
return true
}
/**
* 验证菜单项列表
*/
export function validateMenuItems(items: ContextMenuItem[]): boolean {
if (!Array.isArray(items)) {
console.warn('菜单项列表必须是数组')
return false
}
return items.every(validateMenuItem)
}
/**
* 过滤可见菜单项(移除禁用项和空分隔线)
*/
export function filterVisibleItems(items: ContextMenuItem[]): ContextMenuItem[] {
return items.filter((item) => {
if (item.disabled) return false
if (item.separator && !item.label) return true // 保留纯分隔线
return true
})
}
/**
* 菜单项排序工具
*/
export function sortMenuItems(
items: ContextMenuItem[],
compareFn?: (a: ContextMenuItem, b: ContextMenuItem) => number
): ContextMenuItem[] {
const sorted = [...items]
sorted.sort(
compareFn ||
((a, b) => {
if (!a.label || !b.label) return 0
return a.label.localeCompare(b.label)
})
)
// 递归排序子菜单
return sorted.map((item) => ({
...item,
children: item.children ? sortMenuItems(item.children, compareFn) : undefined
}))
}
/**
* 菜单项分组工具
*/
export function groupMenuItems(items: ContextMenuItem[], groupSize: number = 5): ContextMenuItem[] {
const result: ContextMenuItem[] = []
let currentGroup: ContextMenuItem[] = []
items.forEach((item, index) => {
currentGroup.push(item)
if (currentGroup.length >= groupSize || index === items.length - 1) {
if (currentGroup.length > 0) {
result.push(...currentGroup)
if (index < items.length - 1) {
result.push(createSeparator())
}
currentGroup = []
}
}
})
return result
}
/**
* 菜单项搜索工具
*/
export function searchMenuItems(items: ContextMenuItem[], searchText: string): ContextMenuItem[] {
if (!searchText.trim()) return items
const lowerSearchText = searchText.toLowerCase()
return items.filter((item) => {
if (item.separator) return true
if (!item.label) return false
const matches = item.label.toLowerCase().includes(lowerSearchText)
if (matches) return true
if (item.children && item.children.length > 0) {
const matchingChildren = searchMenuItems(item.children, searchText)
if (matchingChildren.length > 0) {
item.children = matchingChildren
return true
}
}
return false
})
}

View File

@@ -2,7 +2,7 @@
<div class="song-virtual-list">
<!-- 表头 -->
<div class="list-header">
<div v-if="showIndex" class="col-index"></div>
<div v-if="showIndex" class="col-index">#</div>
<div class="col-title">标题</div>
<div v-if="showAlbum" class="col-album">专辑</div>
<div class="col-like">喜欢</div>
@@ -19,6 +19,7 @@
class="song-item"
@mouseenter="hoveredSong = song.id || song.songmid"
@mouseleave="hoveredSong = null"
@contextmenu="handleContextMenu($event, song)"
>
<!-- 序号或播放状态图标 -->
<div v-if="showIndex" class="col-index">
@@ -90,12 +91,33 @@
</div>
</div>
</div>
<!-- 右键菜单 -->
<ContextMenu
v-model:visible="contextMenuVisible"
:position="contextMenuPosition"
:items="contextMenuItems"
@item-click="handleContextMenuItemClick"
@close="closeContextMenu"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { DownloadIcon } from 'tdesign-icons-vue-next'
import { ref, computed, onMounted, nextTick, toRaw } from 'vue'
import {
DownloadIcon,
PlayCircleIcon,
AddIcon,
FolderIcon,
DeleteIcon
} from 'tdesign-icons-vue-next'
import ContextMenu from '../ContextMenu/ContextMenu.vue'
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
import type { ContextMenuItem, ContextMenuPosition } from '../ContextMenu/types'
import songListAPI from '@renderer/api/songList'
import type { SongList } from '@common/types/songList'
import { MessagePlugin } from 'tdesign-vue-next'
interface Song {
id?: number
@@ -120,6 +142,8 @@ interface Props {
showIndex?: boolean
showAlbum?: boolean
showDuration?: boolean
isLocalPlaylist?: boolean
playlistId?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -127,10 +151,19 @@ const props = withDefaults(defineProps<Props>(), {
isPlaying: false,
showIndex: true,
showAlbum: true,
showDuration: true
showDuration: true,
isLocalPlaylist: false,
playlistId: ''
})
const emit = defineEmits(['play', 'pause', 'addToPlaylist', 'download', 'scroll'])
const emit = defineEmits([
'play',
'pause',
'addToPlaylist',
'download',
'scroll',
'removeFromLocalPlaylist'
])
// 虚拟滚动相关状态
const scrollContainer = ref<HTMLElement>()
@@ -142,6 +175,14 @@ const scrollTop = ref(0)
const visibleStartIndex = ref(0)
const visibleEndIndex = ref(0)
// 右键菜单相关状态
const contextMenuVisible = ref(false)
const contextMenuPosition = ref<ContextMenuPosition>({ x: 0, y: 0 })
const contextMenuSong = ref<Song | null>(null)
// 歌单列表
const playlists = ref<SongList[]>([])
// 计算总高度
const totalHeight = computed(() => props.songs.length * itemHeight)
@@ -236,6 +277,131 @@ const onScroll = (event: Event) => {
emit('scroll', event)
}
// 右键菜单项配置
const contextMenuItems = computed((): ContextMenuItem[] => {
const baseItems: ContextMenuItem[] = [
createMenuItem('play', '播放', {
icon: PlayCircleIcon,
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
if (contextMenuSong.value) {
handlePlay(contextMenuSong.value)
}
}
}),
createMenuItem('addToPlaylist', '添加到播放列表', {
icon: AddIcon,
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
if (contextMenuSong.value) {
handleAddToPlaylist(contextMenuSong.value)
}
}
})
]
// 如果有歌单,添加"加入歌单"子菜单
if (playlists.value.length > 0) {
baseItems.push(
createMenuItem('addToSongList', '加入歌单', {
icon: FolderIcon,
children: playlists.value.map((playlist) =>
createMenuItem(`playlist_${playlist.id}`, playlist.name, {
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
if (contextMenuSong.value) {
handleAddToSongList(contextMenuSong.value, playlist)
}
}
})
)
})
)
}
baseItems.push(
createMenuItem('download', '下载', {
icon: DownloadIcon,
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
if (contextMenuSong.value) {
emit('download', contextMenuSong.value)
}
}
})
)
// 添加分隔线
baseItems.push(createSeparator())
// 如果是本地歌单,添加"移出本地歌单"选项
if (props.isLocalPlaylist) {
baseItems.push(
createMenuItem('removeFromLocalPlaylist', '移出当前歌单', {
icon: DeleteIcon,
onClick: (_item: ContextMenuItem, _event: MouseEvent) => {
if (contextMenuSong.value) {
emit('removeFromLocalPlaylist', contextMenuSong.value)
}
}
})
)
}
return baseItems
})
// 处理右键菜单
const handleContextMenu = (event: MouseEvent, song: Song) => {
event.preventDefault()
event.stopPropagation()
// 设置菜单数据
contextMenuSong.value = song
// 使用智能位置计算,确保菜单在可视区域内
contextMenuPosition.value = calculateMenuPosition(event, 240, 300)
// 直接显示菜单
contextMenuVisible.value = true
}
// 处理右键菜单项点击
const handleContextMenuItemClick = (_item: ContextMenuItem, _event: MouseEvent) => {
// 菜单项的 onClick 回调已经在 ContextMenuItem 组件中调用
// 这里不需要额外关闭菜单ContextMenu 组件会处理关闭逻辑
// 避免重复关闭导致菜单显示问题
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenuVisible.value = false
contextMenuSong.value = null
}
// 加载歌单列表
const loadPlaylists = async () => {
try {
const result = await songListAPI.getAll()
if (result.success) {
playlists.value = result.data || []
} else {
console.error('加载歌单失败:', result.error)
}
} catch (error) {
console.error('加载歌单失败:', error)
}
}
// 添加歌曲到歌单
const handleAddToSongList = async (song: Song, playlist: SongList) => {
try {
const result = await songListAPI.addSongs(playlist.id, [toRaw(song) as any])
if (result.success) {
MessagePlugin.success(`已将"${song.name}"添加到歌单"${playlist.name}"`)
} else {
MessagePlugin.error(result.error || '添加到歌单失败')
}
} catch (error) {
console.error('添加到歌单失败:', error)
MessagePlugin.error('添加到歌单失败')
}
}
onMounted(() => {
// 组件挂载后触发一次重新计算
nextTick(() => {
@@ -245,6 +411,9 @@ onMounted(() => {
onScroll(event)
}
})
// 加载歌单列表
loadPlaylists()
})
</script>
@@ -286,7 +455,7 @@ onMounted(() => {
}
.col-title {
padding-left: 10px;
padding-left: 20px;
display: flex;
align-items: center;
}

View File

@@ -16,7 +16,12 @@ import { shouldUseBlackText } from '@renderer/utils/color/contrastColor'
import { ControlAudioStore } from '@renderer/store/ControlAudio'
import { Fullscreen1Icon, FullscreenExit1Icon, ChevronDownIcon } from 'tdesign-icons-vue-next'
// 直接从包路径导入,避免 WebAssembly 导入问题
import { parseYrc, parseLrc, parseTTML } from '@applemusic-like-lyrics/lyric/pkg/amll_lyric.js'
import {
parseYrc,
parseLrc,
parseTTML,
parseQrc
} from '@applemusic-like-lyrics/lyric/pkg/amll_lyric.js'
import _ from 'lodash'
import { storeToRefs } from 'pinia'
@@ -151,7 +156,11 @@ watch(
if (lyricData.crlyric) {
// 使用逐字歌词
lyricText = lyricData.crlyric
parsedLyrics = parseYrc(lyricText)
if (source === 'tx') {
parsedLyrics = parseQrc(lyricText)
} else {
parsedLyrics = parseYrc(lyricText)
}
console.log(`使用${source}逐字歌词`, parsedLyrics)
} else if (lyricData.lyric) {
lyricText = lyricData.lyric
@@ -682,8 +691,8 @@ const lightMainColor = computed(() => {
// bottom: max(2vw, 29px);
height: 200%;
transform: translateY(-25%);
height: 100%;
// transform: translateY(-25%);
* [class^='lyricMainLine'] {
font-weight: 600 !important;

View File

@@ -537,6 +537,33 @@ defineExpose({
color: #ccc;
}
/* 全屏模式下的滚动条样式 - 只显示滑块 */
.playlist-container .playlist-content {
scrollbar-width: thin;
scrollbar-color: rgba(91, 91, 91, 0.3) transparent;
}
.playlist-container.full-screen-mode .playlist-content {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar {
width: 8px;
}
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar-track {
background: transparent;
}
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.playlist-container.full-screen-mode .playlist-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.playlist-container.full-screen-mode .playlist-song:hover {
background-color: rgba(255, 255, 255, 0.1);
}
@@ -589,7 +616,7 @@ defineExpose({
.playlist-content {
flex: 1;
overflow-y: auto;
scrollbar-width: none;
// scrollbar-width: none;
margin: 10px 0;
padding: 0 8px;
}

View File

@@ -95,7 +95,11 @@ const selectSource = (sourceKey: string) => {
// 自动选择该音源的最高音质
const sourceDetail = LocalUserDetail.userInfo.supportedSources?.[sourceKey]
if (sourceDetail && sourceDetail.qualitys && sourceDetail.qualitys.length > 0) {
LocalUserDetail.userInfo.selectQuality = sourceDetail.qualitys[sourceDetail.qualitys.length - 1]
const currentQuality = LocalUserDetail.userInfo.selectQuality
if (!currentQuality || !sourceDetail.qualitys.includes(currentQuality)) {
LocalUserDetail.userInfo.selectQuality =
sourceDetail.qualitys[sourceDetail.qualitys.length - 1]
}
}
// 更新音源图标

View File

@@ -1,12 +1,19 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface TagWriteOptions {
basicInfo: boolean // 基础信息(标题、艺术家、专辑)
cover: boolean // 封面
lyrics: boolean // 普通歌词
}
export interface SettingsState {
showFloatBall: boolean
directories?: {
cacheDir: string
downloadDir: string
}
tagWriteOptions?: TagWriteOptions
}
export const useSettingsStore = defineStore('settings', () => {
@@ -23,7 +30,12 @@ export const useSettingsStore = defineStore('settings', () => {
// 默认设置
return {
showFloatBall: true
showFloatBall: true,
tagWriteOptions: {
basicInfo: true,
cover: true,
lyrics: true
}
}
}

View File

@@ -1,6 +1,7 @@
import { NotifyPlugin, MessagePlugin } from 'tdesign-vue-next'
import { NotifyPlugin, MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { toRaw } from 'vue'
import { useSettingsStore } from '@renderer/store/Settings'
import { toRaw, h } from 'vue'
interface MusicItem {
singer: string
@@ -12,7 +13,7 @@ interface MusicItem {
songmid: number
img: string
lrc: null | string
types: string[]
types: Array<{ type: string; size: string }>
_types: Record<string, any>
typeUrl: Record<string, any>
}
@@ -29,26 +30,236 @@ const qualityMap: Record<string, string> = {
}
const qualityKey = Object.keys(qualityMap)
// 创建音质选择弹窗
function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<string | null> {
return new Promise((resolve) => {
const LocalUserDetail = LocalUserDetailStore()
// 获取歌曲支持的音质列表
const availableQualities = songInfo.types || []
// 检查用户设置的音质是否为特殊音质
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(userQuality)
// 如果是特殊音质且用户支持,添加到选项中(不管歌曲是否有这个音质)
const qualityOptions = [...availableQualities]
if (isSpecialQuality && LocalUserDetail.userSource.quality === userQuality) {
const hasSpecialQuality = availableQualities.some((q) => q.type === userQuality)
if (!hasSpecialQuality) {
qualityOptions.push({ type: userQuality, size: '源站无法得知此音质的文件大小' })
}
}
// 按音质优先级排序
qualityOptions.sort((a, b) => {
const aIndex = qualityKey.indexOf(a.type)
const bIndex = qualityKey.indexOf(b.type)
return bIndex - aIndex // 降序排列,高音质在前
})
const dialog = DialogPlugin.confirm({
header: '选择下载音质(可滚动)',
width: 400,
placement: 'center',
body: () =>
h(
'div',
{
class: 'quality-selector'
},
[
h(
'div',
{
class: 'quality-list',
style: {
maxHeight:
'max(calc(calc(70vh - 2 * var(--td-comp-paddingTB-xxl)) - 24px - 32px - 32px),100px)',
overflow: 'auto',
scrollbarWidth: 'none',
msOverflowStyle: 'none'
}
},
qualityOptions.map((quality) =>
h(
'div',
{
key: quality.type,
class: 'quality-item',
style: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
margin: '8px 0',
border: '1px solid #e7e7e7',
borderRadius: '6px',
cursor: 'pointer',
transition: 'all 0.2s ease',
backgroundColor: quality.type === userQuality ? '#e6f7ff' : '#fff'
},
onClick: () => {
dialog.destroy()
resolve(quality.type)
},
onMouseenter: (e: MouseEvent) => {
const target = e.target as HTMLElement
target.style.backgroundColor = '#f0f9ff'
target.style.borderColor = '#1890ff'
},
onMouseleave: (e: MouseEvent) => {
const target = e.target as HTMLElement
target.style.backgroundColor =
quality.type === userQuality ? '#e6f7ff' : '#fff'
target.style.borderColor = '#e7e7e7'
}
},
[
h('div', { class: 'quality-info' }, [
h(
'div',
{
style: {
fontWeight: '500',
fontSize: '14px',
color: quality.type === userQuality ? '#1890ff' : '#333'
}
},
qualityMap[quality.type] || quality.type
),
h(
'div',
{
style: {
fontSize: '12px',
color: '#999',
marginTop: '2px'
}
},
quality.type.toUpperCase()
)
]),
h(
'div',
{
class: 'quality-size',
style: {
fontSize: '12px',
color: '#666',
fontWeight: '500'
}
},
quality.size
)
]
)
)
)
]
),
confirmBtn: null,
cancelBtn: null,
footer: false
})
})
}
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
try {
const LocalUserDetail = LocalUserDetailStore()
let quality = LocalUserDetail.userSource.quality as string
if (
qualityKey.indexOf(quality) >
qualityKey.indexOf(
(songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
)
) {
quality = (songInfo.types[songInfo.types.length - 1] as unknown as { type: any }).type
const userQuality = LocalUserDetail.userSource.quality as string
const settingsStore = useSettingsStore()
// 获取歌词
const { crlyric, lyric } = await window.api.music.requestSdk('getLyric', {
source: toRaw(songInfo.source),
songInfo: toRaw(songInfo) as any
})
console.log(songInfo)
songInfo.lrc = crlyric && songInfo.source !== 'tx' ? crlyric : lyric
// 显示音质选择弹窗
const selectedQuality = await createQualityDialog(songInfo, userQuality)
// 如果用户取消选择,直接返回
if (!selectedQuality) {
return
}
let quality = selectedQuality
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
// 如果选择的是特殊音质,先尝试下载
if (isSpecialQuality) {
try {
console.log(`尝试下载特殊音质: ${quality} - ${qualityMap[quality]}`)
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
const specialResult = await window.api.music.requestSdk('downloadSingleSong', {
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
source: songInfo.source,
quality,
songInfo: toRaw(songInfo) as any,
tagWriteOptions: settingsStore.settings.tagWriteOptions
})
;(await tip).close()
// 如果成功获取特殊音质链接,处理结果并返回
if (specialResult && 'error' in specialResult && !specialResult.error) {
if (!Object.hasOwn(specialResult, 'path')) {
MessagePlugin.info(specialResult.message)
} else {
await NotifyPlugin.success({
title: '下载成功',
content: `${specialResult.message} 保存位置: ${specialResult.path}`
})
}
return
}
console.log(`下载${qualityMap[quality]}音质失败,重新选择音质`)
MessagePlugin.error('该音质下载失败,请重新选择音质')
// 特殊音质下载失败,重新弹出选择框
const retryQuality = await createQualityDialog(songInfo, userQuality)
if (!retryQuality) {
return
}
quality = retryQuality
} catch (specialError) {
console.log(`下载${qualityMap[quality]}音质出错:`, specialError)
MessagePlugin.error('该音质下载失败,请重新选择音质')
// 特殊音质下载出错,重新弹出选择框
const retryQuality = await createQualityDialog(songInfo, userQuality)
if (!retryQuality) {
return
}
quality = retryQuality
}
}
// 检查选择的音质是否超出歌曲支持的最高音质
const songMaxQuality = songInfo.types[songInfo.types.length - 1]?.type
if (songMaxQuality && qualityKey.indexOf(quality) > qualityKey.indexOf(songMaxQuality)) {
quality = songMaxQuality
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${qualityMap[quality]}`)
}
console.log(`使用音质下载: ${quality} - ${qualityMap[quality]}`)
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
const result = await window.api.music.requestSdk('downloadSingleSong', {
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
source: songInfo.source,
quality,
songInfo: toRaw(songInfo)
songInfo: toRaw(songInfo) as any,
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
})
;(await tip).close()
if (!Object.hasOwn(result, 'path')) {
MessagePlugin.info(result.message)
} else {

View File

@@ -37,19 +37,54 @@ export async function getSongRealUrl(song: SongList): Promise<string> {
const LocalUserDetail = LocalUserDetailStore()
// 通过统一的request方法获取真实的播放URL
let quality = LocalUserDetail.userSource.quality as string
// 检查是否为特殊音质(高清臻音、全景环绕或超清母带)
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
// 如果是特殊音质,先尝试获取对应链接
if (isSpecialQuality) {
try {
console.log(`尝试获取特殊音质: ${quality} - ${qualityMap[quality]}`)
const specialUrlData = await window.api.music.requestSdk('getMusicUrl', {
pluginId: LocalUserDetail.userSource.pluginId as unknown as string,
source: song.source,
songInfo: song as any,
quality
})
// 如果成功获取特殊音质链接,直接返回
if (
typeof specialUrlData === 'string' ||
(typeof specialUrlData === 'object' && !specialUrlData.error)
) {
console.log(`成功获取${qualityMap[quality]}链接`)
return specialUrlData as string
}
console.log(`获取${qualityMap[quality]}链接失败,回退到标准逻辑`)
// 如果获取特殊音质失败,继续执行原有逻辑
} catch (specialError) {
console.log(`获取${qualityMap[quality]}链接出错,回退到标准逻辑:`, specialError)
// 特殊音质获取失败,继续执行原有逻辑
}
}
// 原有逻辑:检查歌曲支持的最高音质
if (
qualityKey.indexOf(quality) >
qualityKey.indexOf((song.types[song.types.length - 1] as unknown as { type: any }).type)
) {
quality = (song.types[song.types.length - 1] as unknown as { type: any }).type
}
console.log(quality)
console.log(`使用音质: ${quality} - ${qualityMap[quality]}`)
const urlData = await window.api.music.requestSdk('getMusicUrl', {
pluginId: LocalUserDetail.userSource.pluginId as unknown as string,
source: song.source,
songInfo: song as any,
quality
})
console.log(urlData)
if (typeof urlData === 'object' && urlData.error) {
throw new Error(urlData.error)

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, toRaw } from 'vue'
import { ref, onMounted, onUnmounted, toRaw, computed } from 'vue'
import { useRoute } from 'vue-router'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { downloadSingleSong } from '@renderer/utils/audio/download'
import SongVirtualList from '@renderer/components/Music/SongVirtualList.vue'
interface MusicItem {
singer: string
@@ -191,7 +190,7 @@ const handlePause = () => {
}
}
const handleDownload = (song: MusicItem) => {
const handleDownload = (song: any) => {
downloadSingleSong(song)
}
@@ -202,6 +201,109 @@ const handleAddToPlaylist = (song: MusicItem) => {
}
}
// 从本地歌单移出歌曲
const handleRemoveFromLocalPlaylist = async (song: MusicItem) => {
try {
const result = await window.api.songList.removeSongs(playlistInfo.value.id, [song.songmid])
if (result.success) {
// 从当前歌曲列表中移除
const index = songs.value.findIndex((s) => s.songmid === song.songmid)
if (index !== -1) {
songs.value.splice(index, 1)
// 更新歌单信息中的歌曲总数
playlistInfo.value.total = songs.value.length
}
MessagePlugin.success(`已将"${song.name}"从歌单中移出`)
} else {
MessagePlugin.error(result.error || '移出歌曲失败')
}
} catch (error) {
console.error('移出歌曲失败:', error)
MessagePlugin.error('移出歌曲失败')
}
}
// 检查是否是本地歌单
const isLocalPlaylist = computed(() => {
return route.query.type === 'local' || route.query.source === 'local'
})
// 文件选择器引用
const fileInputRef = ref<HTMLInputElement | null>(null)
// 滚动相关状态
const scrollY = ref(0)
const isHeaderCompact = ref(false)
const scrollContainer = ref<HTMLElement | null>(null)
const songListRef = ref<any>(null)
// 点击封面修改图片(仅本地歌单)
const handleCoverClick = () => {
if (!isLocalPlaylist.value) return
// 触发文件选择器
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
// 处理文件选择
const handleFileSelect = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
// 检查文件类型
if (!file.type.startsWith('image/')) {
MessagePlugin.error('请选择图片文件')
return
}
// 检查文件大小限制为5MB
if (file.size > 5 * 1024 * 1024) {
MessagePlugin.error('图片文件大小不能超过5MB')
return
}
try {
// 读取文件为base64
const reader = new FileReader()
reader.onload = async (e) => {
const base64Data = e.target?.result as string
try {
// 调用API更新歌单封面
const result = await window.api.songList.updateCover(playlistInfo.value.id, base64Data)
if (result.success) {
// 更新本地显示的封面
playlistInfo.value.cover = base64Data
MessagePlugin.success('封面更新成功')
} else {
MessagePlugin.error(result.error || '封面更新失败')
}
} catch (error) {
console.error('更新封面失败:', error)
MessagePlugin.error('封面更新失败')
}
}
reader.onerror = () => {
MessagePlugin.error('读取图片文件失败')
}
reader.readAsDataURL(file)
} catch (error) {
console.error('处理图片文件失败:', error)
MessagePlugin.error('处理图片文件失败')
}
// 清空文件选择器的值,以便可以重复选择同一个文件
target.value = ''
}
// 替换播放列表的通用函数
const replacePlaylist = (songsToReplace: MusicItem[], shouldShuffle = false) => {
if (!(window as any).musicEmitter) {
@@ -287,28 +389,89 @@ const handleShufflePlaylist = () => {
}
})
}
// 滚动事件处理
const handleScroll = (event?: Event) => {
let scrollTop = 0
if (event && event.target) {
scrollTop = (event.target as HTMLElement).scrollTop
} else if (scrollContainer.value) {
scrollTop = scrollContainer.value.scrollTop
}
scrollY.value = scrollTop
// 当滚动超过100px时启用紧凑模式
isHeaderCompact.value = scrollY.value > 100
}
// 组件挂载时获取数据
onMounted(() => {
fetchPlaylistSongs()
// 延迟添加滚动事件监听,等待 SongVirtualList 组件渲染完成
setTimeout(() => {
// 查找 SongVirtualList 内部的虚拟滚动容器
const virtualListContainer = document.querySelector('.virtual-scroll-container')
if (virtualListContainer) {
scrollContainer.value = virtualListContainer as HTMLElement
virtualListContainer.addEventListener('scroll', handleScroll, { passive: true })
console.log('滚动监听器已添加到:', virtualListContainer)
} else {
console.warn('未找到虚拟滚动容器')
}
}, 200)
})
// 组件卸载时清理事件监听
onUnmounted(() => {
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll)
}
})
</script>
<template>
<div class="list-container">
<!-- 固定头部区域 -->
<div class="fixed-header">
<div class="fixed-header" :class="{ compact: isHeaderCompact }">
<!-- 歌单信息 -->
<div class="playlist-header">
<div class="playlist-cover">
<div class="playlist-header" :class="{ compact: isHeaderCompact }">
<div
class="playlist-cover"
:class="{ clickable: isLocalPlaylist }"
@click="handleCoverClick"
>
<img :src="playlistInfo.cover" :alt="playlistInfo.title" />
<!-- 本地歌单显示编辑提示 -->
<div v-if="isLocalPlaylist" class="cover-overlay">
<svg class="edit-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
<span>点击修改封面</span>
</div>
</div>
<!-- 隐藏的文件选择器 -->
<input
ref="fileInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleFileSelect"
/>
<div class="playlist-details">
<h1 class="playlist-title">{{ playlistInfo.title }}</h1>
<p class="playlist-author">by {{ playlistInfo.author }}</p>
<p class="playlist-stats">{{ playlistInfo.total }} 首歌曲</p>
<p class="playlist-author" :class="{ hidden: isHeaderCompact }">
by {{ playlistInfo.author }}
</p>
<p class="playlist-stats" :class="{ hidden: isHeaderCompact }">
{{ playlistInfo.total || songs.length }} 首歌曲
</p>
<!-- 播放控制按钮 -->
<div class="playlist-actions">
<div class="playlist-actions" :class="{ compact: isHeaderCompact }">
<t-button
theme="primary"
size="medium"
@@ -356,16 +519,21 @@ onMounted(() => {
<div v-else class="song-list-wrapper">
<SongVirtualList
ref="songListRef"
:songs="songs"
:current-song="currentSong"
:is-playing="isPlaying"
:show-index="true"
:show-album="true"
:show-duration="true"
:is-local-playlist="isLocalPlaylist"
:playlist-id="playlistInfo.id"
@play="handlePlay"
@pause="handlePause"
@download="handleDownload"
@add-to-playlist="handleAddToPlaylist"
@remove-from-local-playlist="handleRemoveFromLocalPlaylist"
@scroll="handleScroll"
/>
</div>
</div>
@@ -375,7 +543,7 @@ onMounted(() => {
<style lang="scss" scoped>
.list-container {
box-sizing: border-box;
background: #fafafa;
// background: #fafafa;
box-sizing: border-box;
width: 100%;
padding: 20px;
@@ -444,56 +612,160 @@ onMounted(() => {
background: #fff;
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.compact {
padding: 1rem;
gap: 1rem;
}
&.compact .playlist-cover {
width: 80px !important;
height: 80px !important;
}
.playlist-cover {
width: 120px;
height: 120px;
border-radius: 0.5rem;
overflow: hidden;
flex-shrink: 0;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
// 本地歌单封面可点击样式
&.clickable {
cursor: pointer;
&:hover {
.cover-overlay {
opacity: 1;
}
img {
transform: scale(1.05);
}
}
}
.cover-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
color: white;
font-size: 12px;
text-align: center;
padding: 8px;
.edit-icon {
width: 24px;
height: 24px;
margin-bottom: 4px;
}
span {
font-weight: 500;
line-height: 1.2;
}
}
}
.playlist-details {
flex: 1;
.playlist-title {
font-size: 1.5rem;
font-weight: 600;
color: #111827;
margin: 0 0 0.5rem 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.playlist-header.compact & {
font-size: 1.25rem;
margin: 0 0 0.25rem 0;
}
}
.playlist-author {
font-size: 1rem;
color: #6b7280;
margin: 0 0 0.5rem 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 1;
transform: translateY(0);
&.hidden {
opacity: 0;
transform: translateY(-10px);
margin: 0;
height: 0;
overflow: hidden;
}
}
.playlist-stats {
font-size: 0.875rem;
color: #9ca3af;
margin: 0 0 1rem 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 1;
transform: translateY(0);
&.hidden {
opacity: 0;
transform: translateY(-10px);
margin: 0;
height: 0;
overflow: hidden;
}
}
.playlist-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.compact {
margin-top: 0.5rem;
gap: 0.5rem;
}
.play-btn,
.shuffle-btn {
min-width: 120px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.playlist-actions.compact & {
min-width: 100px;
padding: 6px 12px;
font-size: 0.875rem;
}
.play-icon,
.shuffle-icon {
width: 16px;
height: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
.playlist-actions.compact & {
width: 14px;
height: 14px;
}
}
}
}

View File

@@ -2,11 +2,18 @@
import { ref, onMounted, computed, toRaw } from 'vue'
import { useRouter } from 'vue-router'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { Edit2Icon, ListIcon } from 'tdesign-icons-vue-next'
import { Edit2Icon, PlayCircleIcon, DeleteIcon, ViewListIcon } from 'tdesign-icons-vue-next'
import songListAPI from '@renderer/api/songList'
import type { SongList, Songs } from '@common/types/songList'
import defaultCover from '/default-cover.png'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import ContextMenu from '@renderer/components/ContextMenu/ContextMenu.vue'
import {
createMenuItem,
createSeparator,
calculateMenuPosition
} from '@renderer/components/ContextMenu/utils'
import type { ContextMenuItem, ContextMenuPosition } from '@renderer/components/ContextMenu/types'
// 扩展 Songs 类型以包含本地音乐的额外属性
interface LocalSong extends Songs {
@@ -134,6 +141,11 @@ const editPlaylistForm = ref({
// 当前编辑的歌单
const currentEditingPlaylist = ref<SongList | null>(null)
// 右键菜单状态
const contextMenuVisible = ref(false)
const contextMenuPosition = ref<ContextMenuPosition>({ x: 0, y: 0 })
const contextMenuPlaylist = ref<SongList | null>(null)
// 将时长字符串转换为秒数
const parseInterval = (interval: string): number => {
if (!interval) return 0
@@ -830,6 +842,80 @@ const deleteSong = (song: Songs): void => {
})
}
// 右键菜单项配置
const contextMenuItems = computed((): ContextMenuItem[] => {
if (!contextMenuPlaylist.value) return []
return [
createMenuItem('play', '播放歌单', {
icon: PlayCircleIcon,
onClick: () => {
if (contextMenuPlaylist.value) {
playPlaylist(contextMenuPlaylist.value)
}
}
}),
createMenuItem('view', '查看详情', {
icon: ViewListIcon,
onClick: () => {
if (contextMenuPlaylist.value) {
viewPlaylist(contextMenuPlaylist.value)
}
}
}),
createSeparator(),
createMenuItem('edit', '编辑歌单', {
icon: Edit2Icon,
onClick: () => {
if (contextMenuPlaylist.value) {
editPlaylist(contextMenuPlaylist.value)
}
}
}),
createMenuItem('delete', '删除歌单', {
icon: DeleteIcon,
onClick: async () => {
if (contextMenuPlaylist.value) {
try {
const result = await songListAPI.delete(contextMenuPlaylist.value.id)
if (result.success) {
MessagePlugin.success('歌单删除成功')
await loadPlaylists()
} else {
MessagePlugin.error(result.error || '删除歌单失败')
}
} catch (error) {
console.error('删除歌单失败:', error)
MessagePlugin.error('删除歌单失败')
}
}
}
})
]
})
// 处理歌单右键菜单
const handlePlaylistContextMenu = (event: MouseEvent, playlist: SongList) => {
event.preventDefault()
event.stopPropagation()
contextMenuPlaylist.value = playlist
contextMenuPosition.value = calculateMenuPosition(event)
contextMenuVisible.value = true
}
// 处理右键菜单项点击
const handleContextMenuItemClick = (_item: ContextMenuItem, _event: MouseEvent) => {
// 菜单项的 onClick 回调已经在 ContextMenuItem 组件中调用
// 这里不需要额外处理
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenuVisible.value = false
contextMenuPlaylist.value = null
}
// 组件挂载时加载数据
onMounted(() => {
loadPlaylists()
@@ -894,7 +980,12 @@ onMounted(() => {
<!-- 歌单网格 -->
<div v-else-if="playlists.length > 0" class="playlists-grid">
<div v-for="playlist in playlists" :key="playlist.id" class="playlist-card">
<div
v-for="playlist in playlists"
:key="playlist.id"
class="playlist-card"
@contextmenu="handlePlaylistContextMenu($event, playlist)"
>
<div class="playlist-cover" @click="viewPlaylist(playlist)">
<img
v-if="playlist.coverImgUrl"
@@ -944,7 +1035,11 @@ onMounted(() => {
size="small"
@click="viewPlaylist(playlist)"
>
<ListIcon />
<view-list-icon
:fill-color="'transparent'"
:stroke-color="'#000000'"
:stroke-width="1.5"
/>
</t-button>
</t-tooltip>
<t-tooltip content="编辑歌单">
@@ -1311,6 +1406,15 @@ onMounted(() => {
</div>
</div>
</t-dialog>
<!-- 歌单右键菜单 -->
<ContextMenu
v-model:visible="contextMenuVisible"
:position="contextMenuPosition"
:items="contextMenuItems"
@item-click="handleContextMenuItemClick"
@close="closeContextMenu"
/>
</div>
</template>

View File

@@ -34,6 +34,8 @@ const isPlaying = ref(false)
const search = searchValue()
onMounted(async () => {
const localUserStore = LocalUserDetailStore()
watch(
search,
async () => {
@@ -42,6 +44,17 @@ onMounted(async () => {
},
{ immediate: true }
)
// 监听 userSource 变化,重新加载页面
watch(
() => localUserStore.userSource,
async () => {
if (keyword.value.trim()) {
await performSearch(true)
}
},
{ deep: true }
)
})
// 执行搜索
@@ -133,7 +146,7 @@ const handlePause = () => {
}
}
const handleDownload = (song: MusicItem) => {
const handleDownload = (song: any) => {
downloadSingleSong(song)
}

View File

@@ -21,9 +21,14 @@ import AIFloatBallSettings from '@renderer/components/Settings/AIFloatBallSettin
import ThemeSelector from '@renderer/components/ThemeSelector.vue'
import Versions from '@renderer/components/Versions.vue'
import { useAutoUpdate } from '@renderer/composables/useAutoUpdate'
import { useSettingsStore } from '@renderer/store/Settings'
const Store = LocalUserDetailStore()
const { userInfo } = storeToRefs(Store)
// 设置存储
const settingsStore = useSettingsStore()
const { settings } = storeToRefs(settingsStore)
// 当前选择的设置分类
const activeCategory = ref<string>('appearance')
// 应用版本号
@@ -206,19 +211,25 @@ const qualitySliderValue = ref(0)
const qualityMarks = computed(() => {
const marks: Record<number, string> = {}
currentSourceQualities.value.forEach((quality, index) => {
marks[index] = getQualityDisplayName(quality)
marks[index] = String(getQualityDisplayName(quality))
})
return marks
})
// 监听当前选择的音质,更新滑块位置
watch(
() => userInfo.value.selectQuality,
(newQuality) => {
if (newQuality && currentSourceQualities.value.length > 0) {
const index = currentSourceQualities.value.indexOf(newQuality)
[() => userInfo.value.selectQuality, () => currentSourceQualities.value],
([newQuality, qualities]) => {
if (qualities.length > 0 && newQuality) {
// 检查当前选择的音质是否在新平台的支持列表中
const index = qualities.indexOf(newQuality)
if (index !== -1) {
qualitySliderValue.value = index
} else {
// 如果当前音质不在支持列表中,选择默认音质
console.log('当前音质不在支持列表中,选择默认音质')
// 选择最高音质
userInfo.value.selectQuality = qualities[qualities.length - 1]
}
}
},
@@ -234,7 +245,11 @@ const selectSource = (sourceKey: string) => {
// 自动选择该音源的最高音质
const source = userInfo.value.supportedSources?.[sourceKey]
if (source && source.qualitys && source.qualitys.length > 0) {
userInfo.value.selectQuality = source.qualitys[source.qualitys.length - 1]
// 检查当前选择的音质是否在新平台的支持列表中
const currentQuality = userInfo.value.selectQuality
if (!currentQuality || !source.qualitys.includes(currentQuality)) {
userInfo.value.selectQuality = source.qualitys[source.qualitys.length - 1]
}
}
}
@@ -298,6 +313,30 @@ const getCurrentSourceName = () => {
const openLink = (url: string) => {
window.open(url, '_blank')
}
// 标签写入选项
const tagWriteOptions = ref({
basicInfo: settings.value.tagWriteOptions?.basicInfo ?? true,
cover: settings.value.tagWriteOptions?.cover ?? true,
lyrics: settings.value.tagWriteOptions?.lyrics ?? true
})
// 更新标签写入选项
const updateTagWriteOptions = () => {
settingsStore.updateSettings({
tagWriteOptions: { ...tagWriteOptions.value }
})
}
// 获取标签选项状态描述
const getTagOptionsStatus = () => {
const enabled: string[] = []
if (tagWriteOptions.value.basicInfo) enabled.push('基础信息')
if (tagWriteOptions.value.cover) enabled.push('封面')
if (tagWriteOptions.value.lyrics) enabled.push('歌词')
return enabled.length > 0 ? enabled.join('、') : '未选择任何选项'
}
</script>
<template>
@@ -572,6 +611,44 @@ const openLink = (url: string) => {
<div style="margin-top: 20px" class="setting-group">
<MusicCache ref="musicCacheRef" @cache-cleared="handleCacheCleared" />
</div>
<!-- 标签写入设置 -->
<div class="setting-group">
<h3>下载标签写入设置</h3>
<p>选择下载歌曲时要写入的标签信息</p>
<div class="tag-options">
<div class="tag-option">
<t-checkbox v-model="tagWriteOptions.basicInfo" @change="updateTagWriteOptions">
基础信息
</t-checkbox>
<p class="option-desc">包括歌曲标题艺术家专辑名称等基本信息</p>
</div>
<div class="tag-option">
<t-checkbox v-model="tagWriteOptions.cover" @change="updateTagWriteOptions">
封面
</t-checkbox>
<p class="option-desc">将专辑封面嵌入到音频文件中</p>
</div>
<div class="tag-option">
<t-checkbox v-model="tagWriteOptions.lyrics" @change="updateTagWriteOptions">
普通歌词
</t-checkbox>
<p class="option-desc">将歌词信息写入到音频文件的标签中</p>
</div>
</div>
<div class="tag-options-status">
<div class="status-summary">
<span class="status-label">当前配置</span>
<span class="status-value">
{{ getTagOptionsStatus() }}
</span>
</div>
</div>
</div>
</div>
<!-- 关于页面 -->
@@ -743,7 +820,7 @@ const openLink = (url: string) => {
</p>
</div>
</div>
<h3 style="margin-top: 2rem">关于我们(菜单)</h3>
<h3 style="margin-top: 2rem">关于我们</h3>
<div class="legal-notice">
<div class="notice-item">
<h4>😊 时迁酱</h4>
@@ -782,8 +859,8 @@ const openLink = (url: string) => {
<div class="contact-info">
<p>如有技术问题或合作意向仅限技术交流请通过以下方式联系</p>
<div class="contact-actions">
<t-button theme="primary" @click="openLink('https://qm.qq.com/q/IDpQnbGd06')">
官方QQ群
<t-button theme="primary" @click="openLink('https://qm.qq.com/q/8c25dPfylG')">
官方QQ群(1057783951)
</t-button>
<t-button
theme="primary"
@@ -1773,6 +1850,53 @@ const openLink = (url: string) => {
}
}
// 标签写入设置样式
.tag-options {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
.tag-option {
padding: 1rem;
background: #f8fafc;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
.option-desc {
margin: 0.5rem 0 0 1.5rem;
font-size: 0.875rem;
color: #64748b;
line-height: 1.4;
}
}
}
.tag-options-status {
background: #f8fafc;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
.status-summary {
display: flex;
align-items: center;
gap: 0.5rem;
.status-label {
font-weight: 500;
color: #64748b;
font-size: 0.875rem;
}
.status-value {
font-weight: 600;
color: #1e293b;
font-size: 0.875rem;
}
}
}
// 响应式适配
@media (max-width: 768px) {
.app-header {