/** * 动态背景切换器(Dynamic Background Switcher)— 客户端半区源码 * 版本:pkg-13(最终版) * * 此文件内容即 cordis_define 的 code.client 字段值(完整函数体)。 * 安装方法见同目录 README.md。 * * 功能: * - 上传 GIF/WebP 动图与静态图(多选/拖拽,进度条,逐张即时缩略图) * - 内置 12 色纯色调色板(赤橙黄绿青蓝紫粉白灰黑棕),点击即应用 * - 定时自动切换(3~120s,顺序/随机),双图层丝滑交叉淡入淡出 + Ken Burns * - 主题色保护层:聊天内容列 / 状态栏 / 会话头部(标题、标签、导航) * - 显示模式:完整显示(contain,默认)/ 铺满(cover) * - 可选:背景虚化 / 遮罩强度 * - 状态与定时器常驻插件实例,关闭设置页后背景仍自动切换 */ return { name: 'bgx-dynamic-background', inject: ['timer'], apply(ctx) { const slots = ctx.get('slots') const themeSvc = ctx.get('theme') if (slots === undefined) return // ---- 纯函数工具:不依赖受限全局 ---- const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' function bytesToBase64(bytes) { let out = '' const n = bytes.length let i = 0 while (i < n) { const b0 = bytes[i++] const b1 = i < n ? bytes[i++] : -1 const b2 = i < n ? bytes[i++] : -1 out += B64[b0 >> 2] out += B64[((b0 & 3) << 4) | (b1 < 0 ? 0 : b1 >> 4)] out += b1 < 0 ? '=' : B64[((b1 & 15) << 2) | (b2 < 0 ? 0 : b2 >> 6)] out += b2 < 0 ? '=' : B64[b2 & 63] } return out } function isAnimated(type, name) { return /(gif|webp|apng)/i.test((type || '') + ' ' + (name || '')) } function isImageFile(f) { return /^image\//.test(f.type || '') || /\.(png|jpe?g|gif|webp|apng|svg|bmp|avif)$/i.test(f.name || '') } // ---- 纯色调色板:赤橙黄绿青蓝紫 + 粉白灰黑棕 ---- const PALETTE = [ { name: '赤', color: '#ef4444' }, { name: '橙', color: '#f97316' }, { name: '黄', color: '#eab308' }, { name: '绿', color: '#22c55e' }, { name: '青', color: '#06b6d4' }, { name: '蓝', color: '#3b82f6' }, { name: '紫', color: '#8b5cf6' }, { name: '粉', color: '#ec4899' }, { name: '白', color: '#f8fafc' }, { name: '灰', color: '#64748b' }, { name: '黑', color: '#111318' }, { name: '棕', color: '#a16207' } ] // ---- 基础 CSS:双图层舞台 + 各区域保护层 + 两种 Ken Burns + 减少动态偏好 ---- const BASE_CSS = [ 'body::before,body::after{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;opacity:0;background-position:center;background-repeat:no-repeat;transition:opacity 1400ms cubic-bezier(.4,0,.2,1)}', '@keyframes bgx-ken{from{transform:scale(1)}to{transform:scale(1.05)}}', '@keyframes bgx-ken-cover{from{transform:scale(1.08)}to{transform:scale(1.14)}}', // 聊天内容列(居中限宽)保护层 '[data-chat-flow]{background-color:rgba(30,32,40,.82);background-color:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 84%,transparent)}', // 状态栏(composer.dock 统计行)保护层 '[data-slot="conversation.composer.dock"] > :first-child{background-color:rgba(30,32,40,.82);background-color:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 84%,transparent);border-radius:8px}', // 会话头部整条带(会话名/预设标签/导航标签页)保护层 '[data-slot="conversation.session.header"] > *{background-color:rgba(30,32,40,.82);background-color:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 84%,transparent)}', '@media (prefers-reduced-motion:reduce){body::before,body::after{transition:none!important;animation:none!important}}' ].join('\n') // ---- 设置页 UI 样式 ---- const UI_CSS = [ '.bgx-settings{display:flex;flex-direction:column;gap:12px;max-width:560px;font-size:13px;line-height:1.5;user-select:none}', '.bgx-settings *{box-sizing:border-box}', '.bgx-title{font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px}', '.bgx-dot{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-border-l2)}', '.bgx-dot-on{background:var(--dsw-alias-state-success-primary)}', '.bgx-row{display:flex;align-items:center;gap:10px}', '.bgx-label{flex:1;color:var(--dsw-alias-label-secondary)}', '.bgx-val{min-width:44px;text-align:right;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}', '.bgx-row input[type=range]{flex:2;accent-color:var(--dsw-alias-brand-primary)}', '.bgx-row input[type=checkbox]{accent-color:var(--dsw-alias-brand-primary)}', '.bgx-upload{display:flex;align-items:center;justify-content:center;gap:10px;padding:12px;border:1px dashed var(--dsw-alias-border-l2);border-radius:10px;color:var(--dsw-alias-label-secondary);cursor:pointer;text-align:center}', '.bgx-upload:hover,.bgx-upload-on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}', '.bgx-upload-btn{padding:6px 14px;border-radius:8px;background:var(--dsw-alias-brand-primary);color:#fff;font-size:12px;flex:none}', '.bgx-uploading{display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px}', '.bgx-progress{height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}', '.bgx-progress-fill{height:100%;border-radius:3px;background:var(--dsw-alias-brand-primary);transition:width .2s ease}', '.bgx-palette{display:flex;flex-direction:column;gap:6px}', '.bgx-swatches{display:flex;gap:6px;flex-wrap:wrap}', '.bgx-swatch{width:26px;height:26px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);cursor:pointer;flex:none;transition:transform .12s ease}', '.bgx-swatch:hover{transform:scale(1.12)}', '.bgx-thumbs{display:flex;gap:6px;flex-wrap:wrap}', '.bgx-thumb{position:relative;width:56px;height:56px;border-radius:8px;border:2px solid transparent;overflow:hidden;cursor:pointer;background:var(--dsw-alias-bg-layer-1)}', '.bgx-thumb-on{border-color:var(--dsw-alias-brand-primary)}', '.bgx-thumb img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none}', '.bgx-thumb-swatch{width:100%;height:100%;display:block;pointer-events:none}', '.bgx-badge{position:absolute;left:3px;bottom:3px;font-size:9px;line-height:1.4;padding:0 4px;border-radius:4px;background:rgba(0,0,0,.6);color:#fff}', '.bgx-del{position:absolute;top:1px;right:1px;width:16px;height:16px;border:none;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;font-size:11px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center}', '.bgx-del:hover{background:var(--dsw-alias-state-error-primary)}', '.bgx-empty{color:var(--dsw-alias-label-secondary);text-align:center;padding:12px 0;border:1px dashed var(--dsw-alias-border-l1);border-radius:8px}', '.bgx-hint{color:var(--dsw-alias-label-secondary);font-size:12px}', '.bgx-spacer{flex:1}', '.bgx-btn{padding:5px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);cursor:pointer;font-size:12px}', '.bgx-btn:hover{border-color:var(--dsw-alias-border-l2)}', '.bgx-btn-on{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:#fff}', '.bgx-info{color:var(--dsw-alias-label-secondary);font-size:12px;text-align:center}', '.bgx-error{color:var(--dsw-alias-state-error-primary);font-size:12px}' ].join('\n') styles.insert(BASE_CSS) styles.insert(UI_CSS) // ---- 背景舞台:body::before / body::after 双图层交叉淡入淡出 ---- const stage = { rules: [], visible: null, lastActive: -1, prevActive: -1, drop(sel) { this.rules = this.rules.filter((r) => { if (r.sel !== sel) return true r.dispose() return false }) }, // 图片图层 = 兜底 rgba 规则 + 自适应 color-mix 规则;纯色预设 = 单条纯色规则 insert(sel, img, opacity, ms, scrim, blurPx, mode) { const head = 'content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;opacity:' + opacity + ';transition:opacity ' + ms + 'ms cubic-bezier(.4,0,.2,1);' if (img.solid) { const dispose = styles.insert('body' + sel + '{' + head + 'background:' + img.color + '}') this.rules.push({ sel, dispose }) return } const topPct = Math.max(0, Math.round((scrim - 0.15) * 100)) const botPct = Math.round(scrim * 100) const size = mode === 'cover' ? 'cover' : 'contain' const kenName = mode === 'cover' ? 'bgx-ken-cover' : 'bgx-ken' const blurCss = blurPx > 0 ? 'filter:blur(' + blurPx + 'px);' : '' const fallbackBg = 'linear-gradient(180deg, rgba(18,20,28,' + (topPct / 100).toFixed(3) + ') 0%, rgba(18,20,28,' + (botPct / 100).toFixed(3) + ') 100%), url("' + img.dataUrl + '") center/' + size + ' no-repeat' const adaptiveBg = 'linear-gradient(180deg, color-mix(in srgb, var(--dsw-alias-bg-layer-1) ' + topPct + '%, transparent) 0%, color-mix(in srgb, var(--dsw-alias-bg-layer-1) ' + botPct + '%, transparent) 100%), url("' + img.dataUrl + '") center/' + size + ' no-repeat' const rule = (bg) => 'body' + sel + '{' + head + blurCss + 'background:' + bg + ';background-color:rgba(28,30,38,.45);background-color:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 55%, transparent);animation:' + kenName + ' 16000ms ease-out forwards;}' const d1 = styles.insert(rule(fallbackBg)) const d2 = styles.insert(rule(adaptiveBg)) this.rules.push({ sel, dispose: () => { d1(); d2() } }) }, disposeAll() { for (const r of this.rules) r.dispose() this.rules = [] }, sync(images, active, ms, scrim, blurPx, mode) { const img = images[active] if (!img) { this.disposeAll() this.visible = null this.lastActive = -1 this.prevActive = -1 return } if (this.visible === null) { this.disposeAll() this.insert('::before', img, 1, ms, scrim, blurPx, mode) this.visible = 'before' this.lastActive = active this.prevActive = -1 return } if (this.lastActive === active) { const hidden = this.visible === 'before' ? '::after' : '::before' const prevImg = images[this.prevActive] || img this.drop(hidden) this.insert(hidden, prevImg, 0, ms, scrim, blurPx, mode) return } const prevActive = this.lastActive const prevImg = images[prevActive] || img const outgoing = this.visible const incoming = outgoing === 'before' ? '::after' : '::before' this.lastActive = active this.prevActive = prevActive this.disposeAll() this.insert(outgoing, prevImg, 0, ms, scrim, blurPx, mode) this.insert(incoming, img, 1, ms, scrim, blurPx, mode) this.visible = incoming === '::before' ? 'before' : 'after' }, clear() { this.disposeAll() this.visible = null this.lastActive = -1 this.prevActive = -1 } } // ---- 插件级共享状态(常驻) ---- const state = { enabled: true, playing: true, random: false, mode: 'contain', intervalSec: 15, crossfadeMs: 1400, blurPx: 0, scrimPct: 0, images: [], active: -1 } const listeners = new Set() let themeOverride = null let timerDispose = null function notify() { for (const fn of listeners) fn((v) => (typeof v === 'number' ? v : 0) + 1) } function syncStage() { if (!state.enabled || state.images.length === 0 || !(state.active >= 0 && state.active < state.images.length)) { stage.clear() return } stage.sync(state.images, state.active, state.crossfadeMs, state.scrimPct / 100, state.blurPx, state.mode) } // 画布透明化:背景图在非保护区域完整透出;侧边栏恢复纯色(不参与覆盖) function syncTheme() { const activeOk = state.enabled && state.images.length > 0 && state.active >= 0 && state.active < state.images.length if (!activeOk || !themeSvc) { if (themeOverride) { const d = themeOverride themeOverride = null try { d() } catch (err) {} } return } themeOverride = themeSvc.overrideTokens('bgx-canvas', { '--dsw-alias-bg-base': { light: 'transparent', dark: 'transparent' } }) } function syncTimer() { if (timerDispose) { timerDispose(); timerDispose = null } if (state.enabled && state.playing && state.images.length >= 2) { timerDispose = ctx.interval(() => { const n = state.images.length if (n < 2) return let next if (state.random) { next = Math.floor(Math.random() * n) if (next === state.active && n > 1) next = (next + 1) % n } else { next = (state.active + 1) % n } state.active = next syncStage() notify() }, Math.max(3, state.intervalSec) * 1000) } } function applyChange(mutator) { mutator() syncStage() syncTheme() syncTimer() notify() } // ---- 卸载清理 ---- ctx.effect(() => () => { if (timerDispose) { timerDispose(); timerDispose = null } if (themeOverride) { try { themeOverride() } catch (err) {} themeOverride = null } stage.disposeAll() listeners.clear() }) // ---- 设置页「动态背景」 ---- function BgxSettings() { const [, setTick] = React.useState(0) const [error, setError] = React.useState(null) const [busy, setBusy] = React.useState(false) const [progress, setProgress] = React.useState(null) const [dragOn, setDragOn] = React.useState(false) React.useEffect(() => { listeners.add(setTick) return () => listeners.delete(setTick) }, []) const el = React.createElement const n = state.images.length function addPreset(sw) { applyChange(() => { const img = { id: 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8), name: '纯色·' + sw.name, type: 'preset', size: 0, animated: false, solid: true, color: sw.color, dataUrl: null } state.images = state.images.concat([img]) state.active = state.images.length - 1 }) } async function onFiles(fileList) { const files = Array.from(fileList || []) if (!files.length || busy) return const MAX = 25 * 1024 * 1024 const imageFiles = [] let skipped = 0 for (const f of files) { if (isImageFile(f)) imageFiles.push(f) else skipped++ } if (!imageFiles.length) { setError('未找到图片文件') return } setBusy(true) setError(null) const fails = [] for (let i = 0; i < imageFiles.length; i++) { const f = imageFiles[i] setProgress({ done: i, total: imageFiles.length, name: f.name }) if (i > 0) await ctx.timeout(0) if (f.size > MAX) { fails.push('「' + f.name + '」超过 25MB') continue } try { const buf = await f.arrayBuffer() const dataUrl = 'data:' + (f.type || 'image/png') + ';base64,' + bytesToBase64(new Uint8Array(buf)) const img = { id: 'i' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8), name: f.name, type: f.type, size: f.size, animated: isAnimated(f.type, f.name), dataUrl } applyChange(() => { state.images = state.images.concat([img]) if (!(state.active >= 0 && state.active < state.images.length)) state.active = 0 }) } catch (e) { fails.push('读取「' + f.name + '」失败') } } setBusy(false) setProgress(null) const msgs = [] if (skipped) msgs.push('已跳过 ' + skipped + ' 个非图片文件') if (fails.length) msgs.push(fails.join(';')) setError(msgs.length ? msgs.join(';') : null) } function removeImage(id) { applyChange(() => { const idx = state.images.findIndex((img) => img.id === id) if (idx < 0) return const next = state.images.filter((img) => img.id !== id) state.images = next if (state.active === -1 || next.length === 0) state.active = -1 else if (idx < state.active) state.active -= 1 else if (idx === state.active) state.active = Math.min(state.active, next.length - 1) }) } function clearAll() { applyChange(() => { state.images = [] state.active = -1 }) } const uploadProps = { className: 'bgx-upload' + (dragOn ? ' bgx-upload-on' : ''), onDragOver: (e) => { e.preventDefault(); setDragOn(true) }, onDragLeave: () => setDragOn(false), onDrop: (e) => { e.preventDefault(); setDragOn(false); onFiles(e.dataTransfer.files) } } return el('div', { className: 'bgx-settings' }, el('div', { className: 'bgx-title' }, el('span', null, '🎨 动态背景'), el('span', { className: state.enabled ? 'bgx-dot bgx-dot-on' : 'bgx-dot' }), el('span', { className: 'bgx-hint' }, state.enabled ? '已开启' : '已关闭') ), el('label', { className: 'bgx-row' }, el('span', { className: 'bgx-label' }, '开启背景'), el('input', { type: 'checkbox', checked: state.enabled, onChange: (e) => applyChange(() => { state.enabled = e.target.checked }) }) ), el('div', { className: 'bgx-palette' }, el('span', { className: 'bgx-hint' }, '纯色背景(点击添加并立即应用):'), el('div', { className: 'bgx-swatches' }, PALETTE.map((sw) => el('button', { key: sw.name, className: 'bgx-swatch', style: { background: sw.color }, title: '纯色·' + sw.name, onClick: () => addPreset(sw) }) )) ), el('label', Object.assign({}, uploadProps), el('input', { type: 'file', accept: 'image/*', multiple: true, style: { display: 'none' }, onChange: (e) => { onFiles(e.target.files); e.target.value = '' } }), el('span', { className: 'bgx-upload-btn' }, '选择图片'), el('span', null, '或将图片拖到这里 · 支持多选(Ctrl/Shift)') ), busy && progress ? el('div', { className: 'bgx-uploading' }, el('span', null, '正在处理 ' + (progress.done + 1) + ' / ' + progress.total + ':' + progress.name), el('div', { className: 'bgx-progress' }, el('div', { className: 'bgx-progress-fill', style: { width: Math.round(((progress.done + 1) / progress.total) * 100) + '%' } }) ) ) : null, n > 0 ? el('div', { className: 'bgx-thumbs' }, state.images.map((img, i) => el('div', { key: img.id, className: i === state.active ? 'bgx-thumb bgx-thumb-on' : 'bgx-thumb', onClick: () => applyChange(() => { state.active = i }), title: img.name + (img.animated ? '(动图)' : '') }, img.solid ? el('div', { className: 'bgx-thumb-swatch', style: { background: img.color } }) : el('img', { src: img.dataUrl, alt: img.name }), img.animated ? el('span', { className: 'bgx-badge' }, '动') : null, el('button', { className: 'bgx-del', onClick: (e) => { e.stopPropagation(); removeImage(img.id) }, title: '移除' }, '×') ) )) : el('div', { className: 'bgx-empty' }, '还没有背景,点上面的色块或用「选择图片」添加'), el('div', { className: 'bgx-row' }, el('span', { className: 'bgx-label' }, '显示模式'), el('button', { className: state.mode === 'contain' ? 'bgx-btn bgx-btn-on' : 'bgx-btn', onClick: () => applyChange(() => { state.mode = 'contain' }) }, '完整显示'), el('button', { className: state.mode === 'cover' ? 'bgx-btn bgx-btn-on' : 'bgx-btn', onClick: () => applyChange(() => { state.mode = 'cover' }) }, '铺满') ), el('div', { className: 'bgx-row' }, el('span', { className: 'bgx-label' }, '切换间隔'), el('input', { type: 'range', min: 3, max: 120, step: 1, value: state.intervalSec, onChange: (e) => applyChange(() => { state.intervalSec = Number(e.target.value) }) }), el('span', { className: 'bgx-val' }, state.intervalSec + 's') ), el('div', { className: 'bgx-row' }, el('span', { className: 'bgx-label' }, '过渡时长'), el('input', { type: 'range', min: 3, max: 30, step: 1, value: Math.round(state.crossfadeMs / 100), onChange: (e) => applyChange(() => { state.crossfadeMs = Number(e.target.value) * 100 }) }), el('span', { className: 'bgx-val' }, (state.crossfadeMs / 1000).toFixed(1) + 's') ), el('div', { className: 'bgx-row' }, el('span', { className: 'bgx-label' }, '背景虚化(可选)'), el('input', { type: 'range', min: 0, max: 12, step: 1, value: state.blurPx, onChange: (e) => applyChange(() => { state.blurPx = Number(e.target.value) }) }), el('span', { className: 'bgx-val' }, state.blurPx + 'px') ), el('div', { className: 'bgx-row' }, el('span', { className: 'bgx-label' }, '遮罩强度(可选)'), el('input', { type: 'range', min: 0, max: 90, step: 1, value: state.scrimPct, onChange: (e) => applyChange(() => { state.scrimPct = Number(e.target.value) }) }), el('span', { className: 'bgx-val' }, state.scrimPct + '%') ), el('div', { className: 'bgx-row' }, el('button', { className: state.random ? 'bgx-btn bgx-btn-on' : 'bgx-btn', onClick: () => applyChange(() => { state.random = true }) }, '随机'), el('button', { className: !state.random ? 'bgx-btn bgx-btn-on' : 'bgx-btn', onClick: () => applyChange(() => { state.random = false }) }, '顺序'), el('span', { className: 'bgx-spacer' }), el('button', { className: 'bgx-btn', onClick: () => applyChange(() => { state.playing = !state.playing }) }, state.playing ? '⏸ 暂停' : '▶ 播放'), el('button', { className: 'bgx-btn', onClick: () => applyChange(() => { if (n >= 2) state.active = (state.active + 1) % n }) }, '⏭ 下一张'), el('button', { className: 'bgx-btn', onClick: clearAll }, '清空') ), n > 0 ? el('div', { className: 'bgx-info' }, '当前 ' + (state.active + 1) + ' / ' + n + (state.playing ? ' · 自动切换中' : ' · 已暂停')) : null, el('div', { className: 'bgx-hint' }, '保护层:聊天列 / 状态栏 / 会话头部均已覆盖;侧边栏保持原样式。'), error ? el('div', { className: 'bgx-error' }, error) : null ) } slots.inject('settings.section', () => slots.register( { name: 'settings.section', id: 'bgx', order: 30, label: '动态背景' }, BgxSettings )) } }