feat: configure wallpaper rotation and static images

This commit is contained in:
jxxghp
2026-08-31 09:18:15 +08:00
parent 28d2dba22f
commit 39e8d7ffba
8 changed files with 193 additions and 42 deletions
+27 -18
View File
@@ -46,6 +46,7 @@ import {
BACKGROUND_ROTATION_GRACE_MS,
createBackgroundCandidateOrderResolver,
findFirstAvailableBackground,
normalizeBackgroundRotationIntervalSeconds,
preloadBackgroundRotationImages,
shouldAllowBackgroundRotation,
} from '@/utils/backgroundRotation'
@@ -195,13 +196,18 @@ const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivi
const preferredMotion = usePreferredReducedMotion()
const backgroundRotationGraceActive = ref(false)
let backgroundRotationGraceTimer: number | null = null
const backgroundRotationIntervalMs = computed(
() => normalizeBackgroundRotationIntervalSeconds(globalSettingsStore.get('WALLPAPER_ROTATION_INTERVAL')) * 1000,
)
// 壁纸时钟允许短时后台续跑;指针、滚动和流场仍服从更严格的应用活动状态。
const allowsBackgroundRotation = computed(() =>
shouldAllowBackgroundRotation(
appActivityState.value,
backgroundRotationGraceActive.value,
preferredMotion.value === 'reduce',
),
const allowsBackgroundRotation = computed(
() =>
Boolean(backgroundRotationIntervalMs.value) &&
shouldAllowBackgroundRotation(
appActivityState.value,
backgroundRotationGraceActive.value,
preferredMotion.value === 'reduce',
),
)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isGlassTheme = computed(() => globalTheme.name.value === 'glass')
@@ -549,9 +555,13 @@ async function fetchBackgroundImages() {
const controller = new AbortController()
backgroundRequestController = controller
try {
return await api.get<string[], string[]>(`/login/wallpapers`, {
signal: controller.signal,
})
const [images] = await Promise.all([
api.get<string[], string[]>(`/login/wallpapers`, {
signal: controller.signal,
}),
globalSettingsStore.initialize(),
])
return images
} finally {
if (backgroundRequestController === controller) backgroundRequestController = null
}
@@ -737,15 +747,10 @@ function startBackgroundRotation() {
if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
scheduleNextBackgroundPreload()
// 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。
addBackgroundTimer(
'background-rotation',
() => void rotateBackgroundImage(),
10000, // 每10秒切换一次
{
runInBackground: true,
skipInitialRun: true, // 不需要立即执行
},
)
addBackgroundTimer('background-rotation', () => void rotateBackgroundImage(), backgroundRotationIntervalMs.value, {
runInBackground: true,
skipInitialRun: true, // 不需要立即执行
})
}
}
@@ -779,6 +784,10 @@ watch(allowsBackgroundRotation, allowsRotation => {
}
})
watch(backgroundRotationIntervalMs, () => {
if (backgroundImages.value.length > 1) startBackgroundRotation()
})
// 停止登录页、透明主题或玻璃主题背景图加载、重试和轮播。
function stopBackgroundLoading() {
backgroundLoadVersion += 1
+20
View File
@@ -2399,7 +2399,27 @@ export default {
mediaserver: 'Media Server',
none: 'No Wallpaper',
customize: 'Customize',
static: 'Static Image',
},
wallpaperRotationInterval: 'Wallpaper Rotation Interval',
wallpaperRotationIntervalHint:
'Choose how often the background changes; a single static image will not be rotated',
wallpaperRotationItems: {
never: 'Do not rotate',
seconds15: '15 seconds (default)',
minute1: '1 minute',
minutes5: '5 minutes',
minutes10: '10 minutes',
minutes30: '30 minutes',
hour1: '1 hour',
hours6: '6 hours',
hours12: '12 hours',
day1: '1 day',
},
wallpaperImageUrl: 'Static Wallpaper Address',
wallpaperImageUrlHint: 'Use a site-accessible local path or a full URL directly as the background image',
wallpaperImageUrlPlaceholder: 'For example: /wallpaper.jpg or https://example.com/wallpaper.jpg',
wallpaperImageUrlRequired: 'Required field; please enter a static wallpaper address',
mb: 'MB',
hour: 'hour',
day: 'day',
+19
View File
@@ -2358,7 +2358,26 @@ export default {
mediaserver: '媒体服务器',
none: '无壁纸',
customize: '自定义',
static: '静态图片',
},
wallpaperRotationInterval: '壁纸轮换时间',
wallpaperRotationIntervalHint: '选择背景壁纸的自动轮换间隔,静态图片只有一张时不会触发轮换',
wallpaperRotationItems: {
never: '不轮换',
seconds15: '15秒(默认)',
minute1: '1分钟',
minutes5: '5分钟',
minutes10: '10分钟',
minutes30: '半小时',
hour1: '1小时',
hours6: '6小时',
hours12: '12小时',
day1: '一天',
},
wallpaperImageUrl: '静态壁纸地址',
wallpaperImageUrlHint: '直接使用站点可访问的本地路径或完整 URL 作为背景图片',
wallpaperImageUrlPlaceholder: '例如:/wallpaper.jpg 或 https://example.com/wallpaper.jpg',
wallpaperImageUrlRequired: '必填项;请输入静态壁纸地址',
mb: 'MB',
hour: '小时',
day: '天',
+19
View File
@@ -2357,7 +2357,26 @@ export default {
mediaserver: '媒體服務器',
none: '無壁紙',
customize: '自定義',
static: '靜態圖片',
},
wallpaperRotationInterval: '壁紙輪換時間',
wallpaperRotationIntervalHint: '選擇背景壁紙的自動輪換間隔,靜態圖片只有一張時不會觸發輪換',
wallpaperRotationItems: {
never: '不輪換',
seconds15: '15秒(預設)',
minute1: '1分鐘',
minutes5: '5分鐘',
minutes10: '10分鐘',
minutes30: '半小時',
hour1: '1小時',
hours6: '6小時',
hours12: '12小時',
day1: '一天',
},
wallpaperImageUrl: '靜態壁紙地址',
wallpaperImageUrlHint: '直接使用站點可存取的本地路徑或完整 URL 作為背景圖片',
wallpaperImageUrlPlaceholder: '例如:/wallpaper.jpg 或 https://example.com/wallpaper.jpg',
wallpaperImageUrlRequired: '必填項;請輸入靜態壁紙地址',
mb: 'MB',
hour: '小時',
day: '天',
@@ -1,7 +1,9 @@
import {
BACKGROUND_ROTATION_GRACE_MS,
DEFAULT_BACKGROUND_ROTATION_INTERVAL_SECONDS,
createBackgroundCandidateOrderResolver,
findFirstAvailableBackground,
normalizeBackgroundRotationIntervalSeconds,
preloadBackgroundRotationImages,
shouldAllowBackgroundRotation,
} from '@/utils/backgroundRotation'
@@ -26,6 +28,14 @@ describe('background rotation lifecycle', () => {
expect(shouldAllowBackgroundRotation('idle', false, false)).toBe(false)
expect(shouldAllowBackgroundRotation('active', false, true)).toBe(false)
})
it('normalizes the backend interval while preserving zero as disabled', () => {
expect(DEFAULT_BACKGROUND_ROTATION_INTERVAL_SECONDS).toBe(15)
expect(normalizeBackgroundRotationIntervalSeconds(undefined)).toBe(15)
expect(normalizeBackgroundRotationIntervalSeconds('60')).toBe(60)
expect(normalizeBackgroundRotationIntervalSeconds(0)).toBe(0)
expect(normalizeBackgroundRotationIntervalSeconds(-1)).toBe(15)
})
})
describe('createBackgroundCandidateOrderResolver', () => {
+10
View File
@@ -2,6 +2,8 @@ import type { AppActivityState } from '@/utils/appActivityLifecycle'
/** 壁纸在窗口失焦后继续轮换的最长时间,交互 renderer 仍由应用生命周期独立暂停。 */
export const BACKGROUND_ROTATION_GRACE_MS = 60_000
/** 未配置或配置无效时使用的壁纸轮换间隔。 */
export const DEFAULT_BACKGROUND_ROTATION_INTERVAL_SECONDS = 15
type RandomSource = () => number
@@ -10,6 +12,14 @@ export function shouldAllowBackgroundRotation(state: AppActivityState, graceActi
return !reducedMotion && (state === 'active' || graceActive)
}
/** 将后端设置规范为非负整秒,0 保留为“不轮换”语义。 */
export function normalizeBackgroundRotationIntervalSeconds(value: unknown) {
const interval = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(interval) || interval < 0) return DEFAULT_BACKGROUND_ROTATION_INTERVAL_SECONDS
return Math.floor(interval)
}
/**
*
*/
+59 -23
View File
@@ -45,6 +45,8 @@ const SystemSettings = ref<any>({
APP_DOMAIN: null,
API_TOKEN: null,
WALLPAPER: 'tmdb',
WALLPAPER_ROTATION_INTERVAL: 15,
WALLPAPER_IMAGE_URL: null,
GITHUB_TOKEN: null,
CUSTOMIZE_WALLPAPER_API_URL: null,
AI_AGENT_ENABLE: false,
@@ -995,10 +997,25 @@ const wallpaperItems = [
{ title: t('setting.system.wallpaperItems.tmdb'), value: 'tmdb' },
{ title: t('setting.system.wallpaperItems.bing'), value: 'bing' },
{ title: t('setting.system.wallpaperItems.mediaserver'), value: 'mediaserver' },
{ title: t('setting.system.wallpaperItems.static'), value: 'static' },
{ title: t('setting.system.wallpaperItems.customize'), value: 'customize' },
{ title: t('setting.system.wallpaperItems.none'), value: '' },
]
// 0
const wallpaperRotationIntervalItems = [
{ title: t('setting.system.wallpaperRotationItems.never'), value: 0 },
{ title: t('setting.system.wallpaperRotationItems.seconds15'), value: 15 },
{ title: t('setting.system.wallpaperRotationItems.minute1'), value: 60 },
{ title: t('setting.system.wallpaperRotationItems.minutes5'), value: 300 },
{ title: t('setting.system.wallpaperRotationItems.minutes10'), value: 600 },
{ title: t('setting.system.wallpaperRotationItems.minutes30'), value: 1800 },
{ title: t('setting.system.wallpaperRotationItems.hour1'), value: 3600 },
{ title: t('setting.system.wallpaperRotationItems.hours6'), value: 21600 },
{ title: t('setting.system.wallpaperRotationItems.hours12'), value: 43200 },
{ title: t('setting.system.wallpaperRotationItems.day1'), value: 86400 },
]
// Github
const githubMirrorsItems: string[] = [
// str: 'https://mirror.ghproxy.com/', // GitHub Proxy
@@ -1217,30 +1234,49 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
</VCol>
<VCol cols="12" md="6">
<VRow>
<VCol cols="12" :md="SystemSettings.Basic.WALLPAPER === 'customize' ? 6 : 12">
<VSelect
v-model="SystemSettings.Basic.WALLPAPER"
:label="t('setting.system.wallpaper')"
:hint="t('setting.system.wallpaperHint')"
persistent-hint
:items="wallpaperItems"
prepend-inner-icon="mdi-image"
/>
</VCol>
<VSelect
v-model="SystemSettings.Basic.WALLPAPER"
:label="t('setting.system.wallpaper')"
:hint="t('setting.system.wallpaperHint')"
persistent-hint
:items="wallpaperItems"
prepend-inner-icon="mdi-image"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.WALLPAPER === 'customize'" cols="12" md="6">
<VTextField
v-model="SystemSettings.Basic.CUSTOMIZE_WALLPAPER_API_URL"
:label="t('setting.system.customizeWallpaperApi')"
:hint="t('setting.system.customizeWallpaperApiHint')"
:placeholder="t('setting.system.customizeWallpaperApi')"
persistent-hint
:rules="[v => !!v || t('setting.system.customizeWallpaperApiRequired')]"
prepend-inner-icon="mdi-api"
/>
</VCol>
</VRow>
<VCol v-if="SystemSettings.Basic.WALLPAPER === 'customize'" cols="12" md="6">
<VTextField
v-model="SystemSettings.Basic.CUSTOMIZE_WALLPAPER_API_URL"
:label="t('setting.system.customizeWallpaperApi')"
:hint="t('setting.system.customizeWallpaperApiHint')"
:placeholder="t('setting.system.customizeWallpaperApi')"
persistent-hint
:rules="[v => !!v || t('setting.system.customizeWallpaperApiRequired')]"
prepend-inner-icon="mdi-api"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.WALLPAPER === 'static'" cols="12" md="6">
<VTextField
v-model="SystemSettings.Basic.WALLPAPER_IMAGE_URL"
:label="t('setting.system.wallpaperImageUrl')"
:hint="t('setting.system.wallpaperImageUrlHint')"
:placeholder="t('setting.system.wallpaperImageUrlPlaceholder')"
persistent-hint
:rules="[v => !!v || t('setting.system.wallpaperImageUrlRequired')]"
prepend-inner-icon="mdi-image-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="SystemSettings.Basic.WALLPAPER_ROTATION_INTERVAL"
:label="t('setting.system.wallpaperRotationInterval')"
:hint="t('setting.system.wallpaperRotationIntervalHint')"
persistent-hint
:items="wallpaperRotationIntervalItems"
prepend-inner-icon="mdi-timer-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
@@ -237,6 +237,8 @@ const BASIC_SETTING_KEYS = [
'LLM_USE_PROXY',
'LLM_WEB_SEARCH_MODE',
'WALLPAPER',
'WALLPAPER_IMAGE_URL',
'WALLPAPER_ROTATION_INTERVAL',
]
function mockLoadedSettings() {
@@ -473,7 +475,13 @@ describe('AccountSettingSystem', () => {
expect(await screen.findByLabelText('访问域名')).toHaveValue('https://moviepilot.example')
expect(screen.getByLabelText('API令牌')).toHaveValue('1234567890abcdef')
expect(screen.getByLabelText('背景壁纸')).toHaveValue('tmdb')
const wallpaperSelect = screen.getByLabelText('背景壁纸')
expect(wallpaperSelect).toHaveValue('tmdb')
expect(
within(wallpaperSelect)
.getAllByRole('option')
.map(option => option.textContent),
).toEqual(['TMDB电影海报', 'Bing每日壁纸', '媒体服务器', '静态图片', '自定义', '无壁纸'])
const refreshOptions = mocks.useSilentSettingRefresh.mock.calls[0]?.[1]
expect(refreshOptions.active.value).toBe(true)
@@ -502,6 +510,8 @@ describe('AccountSettingSystem', () => {
GITHUB_TOKEN: null,
LLM_TEMPERATURE: 0.3,
WALLPAPER: 'tmdb',
WALLPAPER_IMAGE_URL: null,
WALLPAPER_ROTATION_INTERVAL: 15,
}),
)
expect(useGlobalSettingsStore(pinia).getData).toEqual(
@@ -744,6 +754,24 @@ describe('AccountSettingSystem', () => {
)
})
it('round-trips a static wallpaper address and a disabled rotation interval', async () => {
await renderSettings()
await screen.findByDisplayValue('https://moviepilot.example')
await selectOption('背景壁纸', '静态图片')
await fireEvent.update(screen.getByLabelText('静态壁纸地址'), '/local/wallpaper.jpg')
await selectOption('壁纸轮换时间', '不轮换')
await fireEvent.click(getBasicCard().getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('基础设置保存成功'))
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
WALLPAPER: 'static',
WALLPAPER_IMAGE_URL: '/local/wallpaper.jpg',
WALLPAPER_ROTATION_INTERVAL: 0,
}),
)
})
it('round-trips the user-editable LLM connection and inference settings', async () => {
const handleProviderSelection = vi.fn()
const applyModelMetadata = vi.fn()