Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ declare module 'vue' {
IconLucideCloudUpload: typeof import('~icons/lucide/cloud-upload')['default']
IconLucideCopy: typeof import('~icons/lucide/copy')['default']
IconLucideDatabase: typeof import('~icons/lucide/database')['default']
IconLucideDisc: typeof import('~icons/lucide/disc')['default']
IconLucideDisc3: typeof import('~icons/lucide/disc3')['default']
IconLucideDownload: typeof import('~icons/lucide/download')['default']
IconLucideEllipsis: typeof import('~icons/lucide/ellipsis')['default']
Expand Down Expand Up @@ -229,6 +228,7 @@ declare module 'vue' {
SFormItem: typeof import('./src/components/ui/SFormItem.vue')['default']
SIconSwap: typeof import('./src/components/ui/SIconSwap.vue')['default']
SideBar: typeof import('./src/components/layout/SideBar.vue')['default']
SidebarCustomizeConfig: typeof import('./src/components/settings/custom/SidebarCustomizeConfig.vue')['default']
SideBarLogo: typeof import('./src/components/layout/SideBarLogo.vue')['default']
SImg: typeof import('./src/components/ui/SImg.vue')['default']
SInput: typeof import('./src/components/ui/SInput.vue')['default']
Expand Down
6 changes: 6 additions & 0 deletions src/assets/icons/type-off.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
299 changes: 181 additions & 118 deletions src/components/layout/SideBar.vue
Original file line number Diff line number Diff line change
@@ -1,28 +1,23 @@
<script setup lang="ts">
import type { SMenuItem } from "@/components/ui/SMenu.vue";
import type { SSelectOption } from "@/components/ui/SSelect.vue";
import type { DropdownMenuItem } from "@/components/ui/SDropdownMenu.vue";
import type { ContentScope } from "@/types/collection";
import { SIDEBAR_GROUP_MY_PLAYLISTS, SIDEBAR_GROUP_SUBSCRIBED } from "@/types/settings";
import { SIDEBAR_NAV_META, applySavedOrder } from "@/components/layout/sidebarNav";
import { useSettingsStore } from "@/stores/settings";
import { useStatusStore } from "@/stores/status";
import { usePlaylistStore } from "@/stores/playlist";
import { useUserStore } from "@/stores/user";
import { useDownloadStore } from "@/stores/download";
import { useHeartMode } from "@/composables/useHeartMode";
import { useSettingsDialog } from "@/settings/useSettingsDialog";
import * as player from "@/core/player";
import IconLucideHome from "~icons/lucide/home";
import IconLucideMusic from "~icons/lucide/music";
import IconLucideUser from "~icons/lucide/user";
import IconLucideDisc3 from "~icons/lucide/disc-3";
import IconLucideFolder from "~icons/lucide/folder";
import IconLucideServer from "~icons/lucide/server";
import IconLucideListMusic from "~icons/lucide/list-music";
import IconMaterialSymbolsFavoriteOutline from "~icons/material-symbols/favorite-outline-rounded";
import IconLucideStar from "~icons/lucide/star";
import IconLucideHistory from "~icons/lucide/history";
import IconLucideDownload from "~icons/lucide/download";
import IconLucideCloud from "~icons/lucide/cloud";
import IconLucidePlus from "~icons/lucide/plus";
import IconLucideChevronDown from "~icons/lucide/chevron-down";
import IconLucideEyeOff from "~icons/lucide/eye-off";
import IconLucideSettings2 from "~icons/lucide/settings-2";
import IconSpHeartMode from "~icons/sp/heart-mode";
import SButton from "@/components/ui/SButton.vue";
import SPopselect from "@/components/ui/SPopselect.vue";
Expand All @@ -36,6 +31,7 @@ const playlistStore = usePlaylistStore();
const userStore = useUserStore();
const downloadStore = useDownloadStore();
const { enterHeartMode } = useHeartMode();
const settingsDialog = useSettingsDialog();

/** 心动模式 */
const toggleHeartMode = useThrottleFn((): void => {
Expand All @@ -58,9 +54,7 @@ const handleCreate = (): void => {

/** 新建成功后跳转到该歌单 */
const handleCreated = (playlistId: string, scope: ContentScope): void => {
router.push(
`/collection/${scope === "local" ? "local" : "netease"}/playlist/${playlistId}`,
);
router.push(`/collection/${scope === "local" ? "local" : "netease"}/playlist/${playlistId}`);
};

/** 我的歌单分组头部 */
Expand Down Expand Up @@ -107,111 +101,177 @@ const renderSubscribedHeader = () =>
),
]);

const hiddenKeys = computed(() => new Set(appearance.sidebarHiddenKeys));

/** 我的歌单 */
const myPlaylistItems = computed<SMenuItem[]>(() => {
const showCover = appearance.sidebarPlaylistCover;
if (status.myPlaylistSource === "local") {
return playlistStore.playlists.map((pl) => ({
key: `/collection/local/playlist/${pl.id}`,
label: pl.title,
icon: markRaw(IconLucideListMusic),
cover: pl.cover ?? "",
showCover,
}));
}
// 在线模式
return userStore.createdPlaylists.slice(1).map((pl) => ({
key: `/collection/netease/playlist/${pl.id}`,
label: pl.name,
icon: markRaw(IconLucideListMusic),
cover: pl.cover ?? "",
showCover,
}));
const hidden = hiddenKeys.value;
const local = status.myPlaylistSource === "local";
const items: SMenuItem[] = local
? playlistStore.playlists.map((pl) => ({
key: `/collection/local/playlist/${pl.id}`,
label: pl.title,
icon: markRaw(IconLucideListMusic),
cover: pl.cover ?? "",
showCover,
}))
: userStore.createdPlaylists.slice(1).map((pl) => ({
key: `/collection/netease/playlist/${pl.id}`,
label: pl.name,
icon: markRaw(IconLucideListMusic),
cover: pl.cover ?? "",
showCover,
}));
const order = local
? appearance.sidebarPlaylistOrder.myLocal
: appearance.sidebarPlaylistOrder.myOnline;
return applySavedOrder(items, order).filter((item) => !hidden.has(item.key));
});

/** 收藏的歌单 */
const subscribedItems = computed<SMenuItem[]>(() => {
const showCover = appearance.sidebarPlaylistCover;
return userStore.subscribedPlaylists.map((pl) => ({
const hidden = hiddenKeys.value;
const items: SMenuItem[] = userStore.subscribedPlaylists.map((pl) => ({
key: `/collection/netease/playlist/${pl.id}`,
label: pl.name,
icon: markRaw(IconLucideListMusic),
cover: pl.cover ?? "",
showCover,
}));
return applySavedOrder(items, appearance.sidebarPlaylistOrder.subscribed).filter(
(item) => !hidden.has(item.key),
);
});

const menuItems = computed<SMenuItem[]>(() => [
// 本地音乐分组
{ key: "/", label: t("nav.home"), icon: markRaw(IconLucideHome) },
{ key: "/library", label: t("nav.library"), icon: markRaw(IconLucideMusic) },
{ key: "/artists/local", label: t("artist.label"), icon: markRaw(IconLucideUser) },
{ key: "/albums/local", label: t("album.label"), icon: markRaw(IconLucideDisc3) },
{ key: "/folders", label: t("folder.label"), icon: markRaw(IconLucideFolder) },
// 个人歌曲
{ key: "divider-personal", type: "divider" },
{
key: "/liked",
label: t("nav.liked"),
icon: markRaw(IconMaterialSymbolsFavoriteOutline),
trailing: () =>
h(
SButton,
{
type: status.heartMode ? "primary" : "default",
variant: "tertiary",
size: 26,
iconSize: 16,
round: true,
onClick: toggleHeartMode,
},
{ icon: () => h(IconSpHeartMode) },
),
},
{ key: "/favorites", label: t("nav.favorites"), icon: markRaw(IconLucideStar) },
{ key: "/cloud", label: t("nav.cloud"), icon: markRaw(IconLucideCloud) },
...(systemSettings.download.enabled
? ([
{
key: "/download",
label: t("nav.download"),
icon: markRaw(IconLucideDownload),
...(downloadStore.activeCount > 0
? {
trailing: () =>
h(
"span",
{
class:
"inline-flex items-center justify-center h-6 min-w-8.5 px-1.5 rounded-full bg-primary/16 text-primary text-xs font-medium tabular-nums leading-none cursor-pointer",
onClick: () => router.push("/download"),
},
String(downloadStore.activeCount),
),
}
: {}),
},
] satisfies SMenuItem[])
: []),
...(systemSettings.streaming.enabled
? ([
{ key: "/streaming", label: t("nav.streaming"), icon: markRaw(IconLucideServer) },
] satisfies SMenuItem[])
: []),
{ key: "/history", label: t("nav.history"), icon: markRaw(IconLucideHistory) },
// 我的歌单
{ key: "divider-playlist", type: "divider" },
{ key: "my-playlist-group", type: "group", render: renderMyHeader },
...myPlaylistItems.value,
// 收藏的歌单
...(status.myPlaylistSource === "online" && subscribedItems.value.length > 0
? ([
{ key: "divider-subscribed", type: "divider" },
{ key: "subscribed-group", type: "group", render: renderSubscribedHeader },
...subscribedItems.value,
] satisfies SMenuItem[])
: []),
]);
/** 「我喜欢」行尾的心动模式按钮 */
const renderHeartModeTrailing = () =>
h(
SButton,
{
type: status.heartMode ? "primary" : "default",
variant: "tertiary",
size: 26,
iconSize: 16,
round: true,
onClick: toggleHeartMode,
},
{ icon: () => h(IconSpHeartMode) },
);

/** 下载任务角标 */
const renderDownloadTrailing = () =>
h(
"span",
{
class:
"inline-flex items-center justify-center h-6 min-w-8.5 px-1.5 rounded-full bg-primary/16 text-primary text-xs font-medium tabular-nums leading-none cursor-pointer",
onClick: () => router.push("/download"),
},
String(downloadStore.activeCount),
);

/** 固定导航区:按分组生成,组间以分隔线或分组名标题区分 */
const navItems = computed<SMenuItem[]>(() => {
const hidden = hiddenKeys.value;
const segments: { title: string; items: SMenuItem[] }[] = [];
for (const group of appearance.sidebarNavGroups) {
const items: SMenuItem[] = [];
for (const key of group.keys) {
if (hidden.has(key)) continue;
const entry = SIDEBAR_NAV_META[key];
if (!entry) continue;
if (key === "/download" && !systemSettings.download.enabled) continue;
if (key === "/streaming" && !systemSettings.streaming.enabled) continue;
const item: SMenuItem = { key, label: t(entry.labelKey), icon: markRaw(entry.icon) };
if (key === "/liked") item.trailing = renderHeartModeTrailing;
if (key === "/download" && downloadStore.activeCount > 0)
item.trailing = renderDownloadTrailing;
items.push(item);
}
if (items.length === 0 && !appearance.sidebarKeepEmptyDivider) continue;
segments.push({ title: group.showName ? group.name.trim() : "", items });
}
const merged: SMenuItem[] = [];
segments.forEach((segment, index) => {
const hasTitle = segment.title.length > 0;
if (index > 0 && (!hasTitle || appearance.sidebarNameWithDivider))
merged.push({ key: `divider-group-${index}`, type: "divider" });
if (hasTitle) {
merged.push({
key: `group-title-${index}`,
type: "group",
render: () =>
h("div", { class: "flex items-center px-3 min-h-10" }, [
h("span", { class: "text-sm text-on-surface-variant/70" }, segment.title),
]),
});
}
merged.push(...segment.items);
});
return merged;
});

const menuItems = computed<SMenuItem[]>(() => {
const hidden = hiddenKeys.value;
const items = [...navItems.value];
const showMy = !hidden.has(SIDEBAR_GROUP_MY_PLAYLISTS);
const showSubscribed =
!hidden.has(SIDEBAR_GROUP_SUBSCRIBED) &&
status.myPlaylistSource === "online" &&
subscribedItems.value.length > 0;
if (showMy || showSubscribed) items.push({ key: "divider-playlist", type: "divider" });
if (showMy) {
items.push({ key: "my-playlist-group", type: "group", render: renderMyHeader });
items.push(...myPlaylistItems.value);
}
if (showSubscribed) {
if (showMy) items.push({ key: "divider-subscribed", type: "divider" });
items.push({ key: "subscribed-group", type: "group", render: renderSubscribedHeader });
items.push(...subscribedItems.value);
}
return items;
});

/** 右键目标菜单项 key,在菜单弹出前由 contextmenu 捕获阶段记录 */
const contextTargetKey = ref("");

const onMenuContextMenu = (event: MouseEvent): void => {
const el = (event.target as HTMLElement).closest("[data-menu-key]");
contextTargetKey.value = el?.getAttribute("data-menu-key") ?? "";
};

const contextMenuItems = computed<DropdownMenuItem[]>(() => {
const target = contextTargetKey.value;
const entry = SIDEBAR_NAV_META[target];
// 歌单项(/collection/ 前缀)与可隐藏导航项均支持右键隐藏
const hideable = entry ? entry.hideable : target.startsWith("/collection/");
return [
{
key: "hide",
label: t("nav.menu.hideFromSidebar"),
icon: markRaw(IconLucideEyeOff),
show: hideable,
},
{
key: "customize",
label: t("nav.menu.customizeSidebar"),
icon: markRaw(IconLucideSettings2),
},
];
});

const onContextMenuSelect = (key: string): void => {
if (key === "hide") {
const target = contextTargetKey.value;
if (target && !appearance.sidebarHiddenKeys.includes(target)) {
appearance.sidebarHiddenKeys.push(target);
}
return;
}
if (key === "customize") settingsDialog.show("appearance", "sidebarCustomize");
};

const activeKey = computed(() => {
// 流媒体
Expand Down Expand Up @@ -245,21 +305,24 @@ onMounted(() => {
<template>
<div class="flex flex-col h-full">
<SideBarLogo :collapsed="appearance.sidebarCollapsed" />
<div
class="flex-1 min-h-0 pb-3 overflow-y-auto transition-[padding] duration-300"
:class="
appearance.sidebarCollapsed
? 'px-2 [&::-webkit-scrollbar]:hidden'
: 'px-3 [scrollbar-gutter:stable]'
"
>
<SMenu
:items="menuItems"
:model-value="activeKey"
:collapsed="appearance.sidebarCollapsed"
@select="onSelect"
/>
</div>
<SContextMenu :items="contextMenuItems" @select="onContextMenuSelect">
<div
class="flex-1 min-h-0 pb-3 overflow-y-auto transition-[padding] duration-300"
:class="
appearance.sidebarCollapsed
? 'px-2 [&::-webkit-scrollbar]:hidden'
: 'px-3 [scrollbar-gutter:stable]'
"
@contextmenu.capture="onMenuContextMenu"
>
<SMenu
:items="menuItems"
:model-value="activeKey"
:collapsed="appearance.sidebarCollapsed"
@select="onSelect"
/>
</div>
</SContextMenu>
<!-- 新建歌单 -->
<PlaylistCreateDialog
v-model:open="createDialogOpen"
Expand Down
Loading