mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
feat: configure wallpaper rotation and static images
This commit is contained in:
+27
-18
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '天',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 为每个前端页面生命周期生成稳定的随机候选顺序;相同来源列表在重试和状态恢复时不会再次洗牌。
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user