diff --git a/components.d.ts b/components.d.ts index 54b43a8..0c5cfd7 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index 3c805f9..7887400 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -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 => { @@ -21,4 +22,5 @@ export const registerIpcHandlers = (): void => { registerApisIpc(); registerLyricsIpc(); registerHotkeyIpc(); + registerThemeIpc(); }; diff --git a/electron/main/ipc/theme.ts b/electron/main/ipc/theme.ts new file mode 100644 index 0000000..b3549ae --- /dev/null +++ b/electron/main/ipc/theme.ts @@ -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/. + * 返回 cache:// URL;用户取消 / 体积超限 / 失败均返回 null + */ + ipcMain.handle("theme:pickBackgroundImage", async (): Promise => { + 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 => { + try { + await fs.rm(bgDir, { recursive: true, force: true }); + } catch (err) { + systemLog.error("[theme] clearBackgroundImages failed", err); + } + }); +}; diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index ab5f087..bc7d4c8 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -40,6 +40,10 @@ declare global { plugins: PluginsApi; apis: ApisApi; lyrics: LyricsApi; + theme: { + pickBackgroundImage: () => Promise; + clearBackgroundImages: () => Promise; + }; hotkey: HotkeyApi; }; } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 4b4d61e..47a9dab 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -295,6 +295,13 @@ const api = { onPositionSync: (callback: (data: unknown) => void) => subscribe("nowPlaying:position-sync", callback), }, + theme: { + // 弹出文件选择框 + pickBackgroundImage: (): Promise => + ipcRenderer.invoke("theme:pickBackgroundImage"), + // 清空已缓存的背景图 + clearBackgroundImages: (): Promise => ipcRenderer.invoke("theme:clearBackgroundImages"), + }, hotkey: { getAll: () => ipcRenderer.invoke("hotkey:getAll"), set: (id: HotkeyActionId, binding: HotkeyBinding) => diff --git a/src/App.vue b/src/App.vue index d584dc7..e8dfa16 100644 --- a/src/App.vue +++ b/src/App.vue @@ -10,5 +10,6 @@ watchEffect(() => { diff --git a/src/components/AppBackground.vue b/src/components/AppBackground.vue new file mode 100644 index 0000000..c6cd665 --- /dev/null +++ b/src/components/AppBackground.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/Versions.vue b/src/components/Versions.vue deleted file mode 100644 index 1bfbfdc..0000000 --- a/src/components/Versions.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/src/components/list/SongList.vue b/src/components/list/SongList.vue index 2fb23a2..b2634d2 100644 --- a/src/components/list/SongList.vue +++ b/src/components/list/SongList.vue @@ -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"; @@ -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, { @@ -504,7 +511,8 @@ defineExpose({