Skip to content
Merged
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: 2 additions & 0 deletions components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ declare module 'vue' {
export interface GlobalComponents {
AbLoopDialog: typeof import('./src/components/modals/AbLoopDialog.vue')['default']
AmllDbServerConfig: typeof import('./src/components/settings/custom/AmllDbServerConfig.vue')['default']
AppBackground: typeof import('./src/components/AppBackground.vue')['default']
AutoCloseDialog: typeof import('./src/components/modals/AutoCloseDialog.vue')['default']
BackgroundImagePicker: typeof import('./src/components/settings/custom/BackgroundImagePicker.vue')['default']
BottomSpectrum: typeof import('./src/components/player/FullPlayer/BottomSpectrum.vue')['default']
ComboboxAnchor: typeof import('reka-ui')['ComboboxAnchor']
ComboboxContent: typeof import('reka-ui')['ComboboxContent']
Expand Down
2 changes: 2 additions & 0 deletions electron/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { registerPluginIpc } from "./plugin";
import { registerApisIpc } from "./apis";
import { registerLyricsIpc } from "./lyrics";
import { registerHotkeyIpc } from "./hotkey";
import { registerThemeIpc } from "./theme";

/** 注册所有 IPC 处理 */
export const registerIpcHandlers = (): void => {
Expand All @@ -21,4 +22,5 @@ export const registerIpcHandlers = (): void => {
registerApisIpc();
registerLyricsIpc();
registerHotkeyIpc();
registerThemeIpc();
};
71 changes: 71 additions & 0 deletions electron/main/ipc/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { dialog, ipcMain } from "electron";
import path from "node:path";
import fs from "node:fs/promises";
import { createHash } from "node:crypto";
import { appCacheDir } from "@main/utils/config";
import { toCacheUrl } from "@main/utils/protocol";
import { systemLog } from "@main/utils/logger";

/** 背景图片缓存目录 */
const bgDir = path.join(appCacheDir, "backgrounds");

/** 允许的图片扩展名 */
const allowedExt = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]);

/** 图片体积上限:30MB */
const MAX_BG_SIZE = 30 * 1024 * 1024;

/**
* 注册主题相关的 IPC
*/
export const registerThemeIpc = (): void => {
/**
* 弹出文件选择框,选择背景图后复制到 app-cache/backgrounds/<sha1>.<ext>
* 返回 cache:// URL;用户取消 / 体积超限 / 失败均返回 null
*/
ipcMain.handle("theme:pickBackgroundImage", async (): Promise<string | null> => {
try {
const result = await dialog.showOpenDialog({
title: "选择背景图片",
properties: ["openFile"],
filters: [{ name: "Images", extensions: ["jpg", "jpeg", "png", "webp", "bmp", "gif"] }],
});
if (result.canceled) return null;
const src = result.filePaths[0];
if (!src) return null;
const ext = path.extname(src).toLowerCase();
if (!allowedExt.has(ext)) return null;
// 体积校验
const stat = await fs.stat(src);
if (stat.size > MAX_BG_SIZE) {
systemLog.warn(
`[theme] background image too large: ${stat.size} bytes (max ${MAX_BG_SIZE})`,
);
return null;
}
const data = await fs.readFile(src);
// 内容 hash 作为文件名
const hash = createHash("sha1").update(data).digest("hex").slice(0, 16);
// 清空整个目录再写入新图
await fs.rm(bgDir, { recursive: true, force: true });
await fs.mkdir(bgDir, { recursive: true });
const dest = path.join(bgDir, `${hash}${ext}`);
await fs.writeFile(dest, data);
return toCacheUrl(dest) ?? null;
} catch (err) {
systemLog.error("[theme] pickBackgroundImage failed", err);
return null;
}
});

/**
* 清空所有缓存的背景图
*/
ipcMain.handle("theme:clearBackgroundImages", async (): Promise<void> => {
try {
await fs.rm(bgDir, { recursive: true, force: true });
} catch (err) {
systemLog.error("[theme] clearBackgroundImages failed", err);
}
});
};
4 changes: 4 additions & 0 deletions electron/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ declare global {
plugins: PluginsApi;
apis: ApisApi;
lyrics: LyricsApi;
theme: {
pickBackgroundImage: () => Promise<string | null>;
clearBackgroundImages: () => Promise<void>;
};
hotkey: HotkeyApi;
};
}
Expand Down
7 changes: 7 additions & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ const api = {
onPositionSync: (callback: (data: unknown) => void) =>
subscribe("nowPlaying:position-sync", callback),
},
theme: {
// 弹出文件选择框
pickBackgroundImage: (): Promise<string | null> =>
ipcRenderer.invoke("theme:pickBackgroundImage"),
// 清空已缓存的背景图
clearBackgroundImages: (): Promise<void> => ipcRenderer.invoke("theme:clearBackgroundImages"),
},
hotkey: {
getAll: () => ipcRenderer.invoke("hotkey:getAll"),
set: (id: HotkeyActionId, binding: HotkeyBinding) =>
Expand Down
1 change: 1 addition & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ watchEffect(() => {
</script>

<template>
<AppBackground />
<RouterView />
</template>
29 changes: 29 additions & 0 deletions src/components/AppBackground.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useThemeStore } from "@/stores/theme";

const theme = useThemeStore();

const visible = computed(() => theme.effectiveStyle === "image" && !!theme.imageBackground.src);

const imageStyle = computed<Record<string, string>>(() => ({
objectFit: "cover",
filter: theme.imageBackground.blur > 0 ? `blur(${theme.imageBackground.blur}px)` : "none",
transform: `scale(${theme.imageBackground.scale})`,
}));
</script>

<template>
<div v-if="visible" class="fixed inset-0 -z-10 pointer-events-none overflow-hidden">
<img
:src="theme.imageBackground.src"
class="w-full h-full select-none"
:style="imageStyle"
draggable="false"
alt=""
/>
<div
class="absolute inset-0 bg-black transition-opacity"
:style="{ opacity: theme.imageBackground.dim }"
/>
</div>
</template>
13 changes: 0 additions & 13 deletions src/components/Versions.vue

This file was deleted.

10 changes: 9 additions & 1 deletion src/components/list/SongList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Track, TrackSource } from "@shared/types/player";
import type { CollectionType } from "@/types/collection";
import { useMediaStore } from "@/stores/media";
import { useStatusStore } from "@/stores/status";
import { useSettingsStore } from "@/stores/settings";
import { useTrackMenu } from "@/composables/useTrackMenu";
import { useMultiSelect } from "@/composables/useMultiSelect";
import { formatTime } from "@/utils/time";
Expand Down Expand Up @@ -58,6 +59,12 @@ const props = withDefaults(
const { t } = useI18n();
const media = useMediaStore();
const status = useStatusStore();
const settings = useSettingsStore();

/** 悬浮布局且播放栏可见时 */
const isFloatingPlayerBar = computed(
() => settings.appearance.layoutMode === "floating" && !!media.track,
);

/** 排序器 默认使用 base 敏感度,忽略大小写 */
const textCollator = new Intl.Collator(undefined, {
Expand Down Expand Up @@ -504,7 +511,8 @@ defineExpose({
<Transition name="fade">
<div
v-if="playingIndex >= 0 && !batch.active.value"
class="absolute right-6 bottom-5 z-20 rounded-full bg-surface-panel shadow-lg border border-solid border-primary/10"
class="absolute right-6 z-20 rounded-full bg-surface-panel backdrop-blur-2xl backdrop-saturate-150 shadow-lg border border-solid border-primary/10 transition-[bottom] duration-300"
:class="isFloatingPlayerBar ? 'bottom-26' : 'bottom-5'"
>
<SButton type="primary" variant="bordered" circle :size="40" @click="scrollToPlaying">
<template #icon>
Expand Down
Loading
Loading