diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6710282 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,44 @@ +name: Build + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [macos-latest, windows-latest] + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + cache: "yarn" + + - name: Install Dependencies + run: yarn + + - name: Build Release Files + run: yarn build + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + - name: Release + uses: softprops/action-gh-release@v1 + with: + files: | + release/*/*.dmg + release/*/*.exe + release/*/*.blockmap + release/*/latest*.yml + draft: false + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ca0c3c --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +dist-electron +release +*.local + +# Editor directories and files +.vscode/.debug.env +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +#lockfile +pnpm-lock.yaml +/test-results/ +/playwright-report/ +/playwright/.cache/ + + + +/wallpaper +/userData diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c483022 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +shamefully-hoist=true \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..544138b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2876e67 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Jarvay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e3307b --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +
+

Workpaper

+ + + + + + + + + +
+ +## 一个定时换壁纸的软件,你可以在不同时间段设置不同的壁纸。 + +比如在工作日9:00-10:30显示壁纸[摸鱼中],
+10:31-11:59显示壁纸[今天吃什么],
+12:00开始显示[干饭],
+在周五整天显示[老子明天不上班]。 + +### 支持功能 +- 支持Mac及Windows +- 支持动态及静态壁纸 +- 支持不同屏幕显示不同壁纸 +- 支持按时间段显示固定壁纸或定时更换壁纸 + + +### 使用方法 +从[release](https://github.com/Jarvay/Workpaper/release)中下载并安装即可 + diff --git a/build/icon.icns b/build/icon.icns new file mode 100644 index 0000000..bb89c5b Binary files /dev/null and b/build/icon.icns differ diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 0000000..6eaf8df Binary files /dev/null and b/build/icon.ico differ diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000..400ed1b Binary files /dev/null and b/build/icon.png differ diff --git a/cross/consts.ts b/cross/consts.ts new file mode 100644 index 0000000..7d0b06d --- /dev/null +++ b/cross/consts.ts @@ -0,0 +1,10 @@ +import { WallpaperType } from './enums'; + +export const IMAGE_EXT_LIST = ['jpg', 'jpeg', 'png', 'heic', 'webp']; + +export const VIDEO_EXT_LIST = ['mp4']; + +export const WALLPAPER_TYPE_ROUTES = { + [WallpaperType.Image]: 'static', + [WallpaperType.Video]: 'live', +}; diff --git a/cross/date.ts b/cross/date.ts new file mode 100644 index 0000000..094c303 --- /dev/null +++ b/cross/date.ts @@ -0,0 +1,8 @@ +import dayjs from 'dayjs'; + +export function timeToSeconds(timeStr: string) { + const day = '2001-01-01'; + const dateStart = dayjs(`${day} 00:00`); + const timeDayjs = dayjs(`${day} ${timeStr}`); + return timeDayjs.diff(dateStart, 'seconds'); +} diff --git a/cross/enums.ts b/cross/enums.ts new file mode 100644 index 0000000..5da98c9 --- /dev/null +++ b/cross/enums.ts @@ -0,0 +1,81 @@ +export enum ChangeType { + Fixed, + AutoChange, +} + +export enum FormMode { + Create, + Update, +} + +export enum Events { + SelectImage = 'SelectImage', + SelectVideo = 'SelectVideo', + SelectDir = 'SelectDir', + + ResetSchedule = 'ResetSchedule', + + SaveRules = 'SaveRules', + SaveWeekdays = 'SaveWeekdays', + + GetLocale = 'GetLocale', + SettingsChange = 'SettingsChange', + InitSettings = 'InitSettings', + + GetPlatform = 'GetPlatform', + + SetStaticWallpaper = 'SetStaticWallpaper', + SetLiveWallpaper = 'SetLiveWallpaper', + SetLiveWallpaperMuted = 'SetLiveWallpaperMuted', + SetLiveWallpaperVolume = 'SetLiveWallpaperVolume', + + SetDBItem = 'SetDBItem', + GetDBItem = 'GetDBItem', + + WallpaperWinReady = 'WallpaperWinReady', + LiveWallpaperLoaded = 'LiveWallpaperLoaded', + StaticWallpaperLoaded = 'StaticWallpaperLoaded', + + GetVersion = 'GetVersion', + OpenExternal = 'OpenExternal', +} + +export enum Locale { + zhCN = 'zhCN', + enUS = 'enUS', +} + +export enum WindowsScaleMode { + Center = 'center', + Stretch = 'stretch', + Fit = 'fit', + Fill = 'fill', + Span = 'span', + Tile = 'tile', +} + +export enum MacOSScaleMode { + Center = 'center', + Stretch = 'stretch', + Fit = 'fit', + Fill = 'fill', + Auto = 'auto', +} + +export enum WebScaleMode { + Fill = 'fill', + Contain = 'contain', + Cover = 'cover', + None = 'none', + ScaleDown = 'scale-down', +} + +export enum WallpaperType { + Image = 'Image', + Video = 'Video', +} + +export enum WallpaperMode { + Replace = 'replace', + Cover = 'cover', +} diff --git a/cross/interface.ts b/cross/interface.ts new file mode 100644 index 0000000..fe9cc14 --- /dev/null +++ b/cross/interface.ts @@ -0,0 +1,69 @@ +import { ModalProps } from 'antd'; +import { + ChangeType, + FormMode, + Locale, + MacOSScaleMode, + WallpaperMode, + WallpaperType, + WebScaleMode, + WindowsScaleMode, +} from './enums'; +import { ITranslation } from './locale/i-translation'; + +export interface ModalFormProps extends ModalProps { + values?: ValueType; + onChange?: (data?: ValueType) => Promise | void; + mode?: FormMode; +} + +export interface Rule { + start: string; + end: string; + wallpaperType: WallpaperType; + type: ChangeType; + path: string; + paths: string[]; + interval?: number; + id?: string; + weekdayId: Weekday['id']; + remark?: string; + isRandom?: boolean; + screenRandom?: boolean; +} + +export interface Weekday { + days: number[]; + id?: string | number; +} + +export interface Settings { + locale: Locale; + scaleMode?: WindowsScaleMode | MacOSScaleMode | null; + webScaleMode: WebScaleMode; + wallpaperMode: WallpaperMode; + volume: number; + muted: boolean; + autoCheckUpdate: boolean; + openAtLogin: boolean; +} + +export type TranslationFunc = (key: keyof ITranslation) => string; + +export interface DBData { + rules: Rule[]; + weekdays: Weekday[]; + settings: Settings; + currentIndex: number; +} + +export interface IDBService { + setItem( + key: Key, + data: DBData[Key], + ): void | Promise; + + getItem( + key: Key, + ): DBData[Key] | Promise; +} diff --git a/cross/locale/en/index.ts b/cross/locale/en/index.ts new file mode 100644 index 0000000..d54df0c --- /dev/null +++ b/cross/locale/en/index.ts @@ -0,0 +1,78 @@ +import { ITranslation } from '../i-translation'; + +export const en: ITranslation = { + showWindow: 'Show window', + startAtLogin: 'Start at login', + exit: 'Exit', + debug: 'Debug', + help: 'Help', + checkUpdate: 'Check update', + autoCheckUpdate: 'Auto check update', + download: 'Download', + update: 'Update', + currentVersion: 'Current version', + latestVersion: 'Latest version', + close: 'Close', + wallpaperMode: 'Wallpaper Mode', + 'wallpaperMode.replace': 'Replace system wallpaper', + 'wallpaperMode.cover': 'Cover on current wallpaper', + + check: 'Detail', + create: 'Create', + edit: 'Edit', + delete: 'Delete', + yes: 'Yes', + no: 'No', + + monday: 'Mon', + tuesday: 'Tue', + wednesday: 'Wed', + thursday: 'Thu', + friday: 'Fri', + saturday: 'Sat', + sunday: 'Sun', + + operationSuccess: 'Success', + deleteConfirmTips: 'Are you sure to delete this item?', + selectPeriodTips: 'Please select period', + + operation: 'Operation', + choose: 'Choose', + language: 'Language', + settings: 'Settings', + + 'rule.timeSlot': 'Time slot', + 'rule.period': 'Period', + 'rule.wallpaperType': 'Wallpaper type', + 'rule.wallpaperType.image': 'Image', + 'rule.wallpaperType.video': 'Video', + 'rule.type': 'Type', + 'rule.type.fixed': 'Fixed', + 'rule.type.autoChange': 'Auto change', + 'rule.path': 'Path/Directory', + 'rule.screen': 'Screen', + 'rule.interval': 'Change interval(seconds)', + 'rule.errMsg.interval.conflicts': 'Interval is conflicting', + 'rule.remark': 'Remark', + 'rule.isRandom': 'Random', + 'rule.screenRandom': 'Random alone every screen', + 'rule.addScreen': 'Add screen', + 'rule.paths.requiredMessage': 'Please select wallpaper', + + scaleMode: 'Scale Mode', + 'scaleMode.default': 'Default', + 'scaleMode.auto': 'Auto', + 'scaleMode.center': 'Center', + 'scaleMode.fill': 'Fill', + 'scaleMode.fit': 'Fit', + 'scaleMode.span': 'Span', + 'scaleMode.stretch': 'Stretch', + 'scaleMode.tile': 'Tile', + + 'webScaleMode.fill': 'Stretch', + 'webScaleMode.contain': 'Fit', + 'webScaleMode.cover': 'Fill', + + 'settings.mute': 'Mute', + 'settings.volume': 'Volume', +}; diff --git a/cross/locale/i-translation.ts b/cross/locale/i-translation.ts new file mode 100644 index 0000000..76180d5 --- /dev/null +++ b/cross/locale/i-translation.ts @@ -0,0 +1,76 @@ +export interface ITranslation { + showWindow: string; + startAtLogin: string; + exit: string; + debug: string; + help: string; + checkUpdate: string; + autoCheckUpdate: string; + download: string; + update: string; + currentVersion: string; + latestVersion: string; + close: string; + wallpaperMode: string; + 'wallpaperMode.replace': string; + 'wallpaperMode.cover': string; + + check: string; + create: string; + edit: string; + delete: string; + yes: string; + no: string; + + monday: string; + tuesday: string; + wednesday: string; + thursday: string; + friday: string; + saturday: string; + sunday: string; + + operationSuccess: string; + deleteConfirmTips: string; + selectPeriodTips: string; + + operation: string; + choose: string; + language: string; + settings: string; + + 'rule.timeSlot': string; + 'rule.period': string; + 'rule.wallpaperType': string; + 'rule.wallpaperType.image': string; + 'rule.wallpaperType.video': string; + 'rule.type': string; + 'rule.type.fixed': string; + 'rule.type.autoChange': string; + 'rule.path': string; + 'rule.screen': string; + 'rule.interval': string; + 'rule.errMsg.interval.conflicts': string; + 'rule.remark': string; + 'rule.isRandom': string; + 'rule.screenRandom': string; + 'rule.addScreen': string; + 'rule.paths.requiredMessage': string; + + scaleMode: string; + 'scaleMode.default': string; + 'scaleMode.fill': string; + 'scaleMode.fit': string; + 'scaleMode.stretch': string; + 'scaleMode.center': string; + 'scaleMode.span': string; + 'scaleMode.tile': string; + 'scaleMode.auto': string; + + 'webScaleMode.fill': string; + 'webScaleMode.contain': string; + 'webScaleMode.cover': string; + + 'settings.mute': string; + 'settings.volume': string; +} diff --git a/cross/locale/index.ts b/cross/locale/index.ts new file mode 100644 index 0000000..4419e22 --- /dev/null +++ b/cross/locale/index.ts @@ -0,0 +1,22 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { en } from './en'; +import { zhCN } from './zh-cn'; +import { settingsService } from '@/services/settings'; +import { Locale } from '../enums'; + +async function initI18next() { + i18n.use(initReactI18next).init({ + resources: { + [Locale.enUS]: { + translation: en, + }, + [Locale.zhCN]: { + translation: zhCN, + }, + }, + lng: (await settingsService.get()).locale, + }); +} + +initI18next(); diff --git a/cross/locale/zh-cn/index.ts b/cross/locale/zh-cn/index.ts new file mode 100644 index 0000000..61ff2c4 --- /dev/null +++ b/cross/locale/zh-cn/index.ts @@ -0,0 +1,78 @@ +import { ITranslation } from '../i-translation'; + +export const zhCN: ITranslation = { + showWindow: '显示界面', + startAtLogin: '开机启动', + exit: '退出', + debug: '调试', + help: '帮助', + checkUpdate: '检查更新', + autoCheckUpdate: '自动检查更新', + download: '下载', + update: '更新', + currentVersion: '当前版本', + latestVersion: '最新版本', + close: '关闭', + wallpaperMode: '壁纸模式', + 'wallpaperMode.replace': '替换原壁纸', + 'wallpaperMode.cover': '覆盖在原壁纸上', + + check: '查看', + create: '创建', + edit: '编辑', + delete: '删除', + yes: '是', + no: '否', + + monday: '星期一', + tuesday: '星期二', + wednesday: '星期三', + thursday: '星期四', + friday: '星期五', + saturday: '星期六', + sunday: '星期日', + + operationSuccess: '操作成功', + deleteConfirmTips: '确定要删除此项吗?', + selectPeriodTips: '请选择周期', + + operation: '操作', + choose: '选择', + language: '语言', + settings: '设置', + + 'rule.timeSlot': '时间段', + 'rule.period': '周期', + 'rule.wallpaperType': '壁纸类型', + 'rule.wallpaperType.image': '图片', + 'rule.wallpaperType.video': '视频', + 'rule.type': '类型', + 'rule.type.fixed': '固定壁纸', + 'rule.type.autoChange': '自动换', + 'rule.path': '壁纸目录/路径', + 'rule.screen': '屏幕', + 'rule.interval': '更换间隔(秒)', + 'rule.errMsg.interval.conflicts': '时间段冲突', + 'rule.remark': '备注', + 'rule.isRandom': '随机切换', + 'rule.screenRandom': '屏幕各自随机', + 'rule.addScreen': '添加屏幕', + 'rule.paths.requiredMessage': '请选择壁纸', + + scaleMode: '缩放模式', + 'scaleMode.default': '默认', + 'scaleMode.auto': '自动', + 'scaleMode.center': '居中', + 'scaleMode.fill': '填充', + 'scaleMode.fit': '适应', + 'scaleMode.span': '跨区', + 'scaleMode.stretch': '拉伸', + 'scaleMode.tile': '平铺', + + 'webScaleMode.fill': '拉伸', + 'webScaleMode.contain': '适应', + 'webScaleMode.cover': '填充', + + 'settings.mute': '静音', + 'settings.volume': '音量', +}; diff --git a/electron-builder.json5 b/electron-builder.json5 new file mode 100644 index 0000000..ca66802 --- /dev/null +++ b/electron-builder.json5 @@ -0,0 +1,47 @@ +/** + * @see https://www.electron.build/configuration/configuration + */ +{ + "appId": "YourAppID", + "productName": "Workpaper", + "asar": true, + "directories": { + "output": "release/${version}" + }, + "files": [ + "dist-electron", + "dist" + ], + "mac": { + "artifactName": "${productName}_${version}.${ext}", + "target": [ + "dmg", + ] + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "artifactName": "${productName}_${version}.${ext}" + }, + "nsis": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true, + "deleteAppDataOnUninstall": false + }, + "publish": [ + "github" + ], + "extraResources": [ + { + "from": "wallpaper/${platform}", + "to": "app/wallpaper/${platform}" + } + ] +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts new file mode 100644 index 0000000..2ed41e2 --- /dev/null +++ b/electron/electron-env.d.ts @@ -0,0 +1,11 @@ +/// + +declare namespace NodeJS { + interface ProcessEnv { + VSCODE_DEBUG?: 'true'; + DIST_ELECTRON: string; + DIST: string; + /** /dist/ or /public/ */ + VITE_PUBLIC: string; + } +} diff --git a/electron/main/handlers.ts b/electron/main/handlers.ts new file mode 100644 index 0000000..f99342c --- /dev/null +++ b/electron/main/handlers.ts @@ -0,0 +1,111 @@ +import { + app, + BrowserWindow, + dialog, + ipcMain, + powerMonitor, + shell, +} from 'electron'; +import { Events } from '../../cross/enums'; +import { + Rule, + Settings, + TranslationFunc, + Weekday, +} from '../../cross/interface'; +import { resetSchedule } from './services/wallpaper'; +import { IMAGE_EXT_LIST, VIDEO_EXT_LIST } from '../../cross/consts'; +import { platform } from 'os'; +import { t as _t, changeLanguage } from 'i18next'; +import { configServiceMain } from './services/db-service'; +import { setLiveWallpaperVolume } from './services/wallpaper-window'; +import { setTray } from './tray'; + +const t = _t as TranslationFunc; + +export function registerHandlers(createWindow: () => Promise) { + ipcMain.handle(Events.SelectImage, () => { + return dialog.showOpenDialogSync({ + filters: [ + { name: t('rule.wallpaperType.image'), extensions: IMAGE_EXT_LIST }, + ], + properties: ['openFile'], + }); + }); + + ipcMain.handle(Events.SelectVideo, () => { + return dialog.showOpenDialogSync({ + filters: [ + { name: t('rule.wallpaperType.video'), extensions: VIDEO_EXT_LIST }, + ], + properties: ['openFile'], + }); + }); + + ipcMain.handle(Events.SelectDir, () => { + return dialog.showOpenDialogSync({ + properties: ['openDirectory'], + }); + }); + + ipcMain.handle(Events.ResetSchedule, async (event) => { + await resetSchedule(); + }); + + ipcMain.handle(Events.SaveRules, (event, rules: Rule[]) => { + configServiceMain.setItem('rules', rules); + }); + + ipcMain.handle(Events.SaveWeekdays, (event, weekdays: Weekday[]) => { + configServiceMain.setItem('weekdays', weekdays); + }); + + ipcMain.handle(Events.GetLocale, () => { + return app.getLocale(); + }); + + ipcMain.handle(Events.GetPlatform, () => { + return platform(); + }); + + ipcMain.handle(Events.SettingsChange, async (_, settings: Settings) => { + const oldSettings = configServiceMain.getItem('settings'); + configServiceMain.setItem('settings', settings); + if (settings.locale !== oldSettings.locale) { + await changeLanguage(settings.locale); + setTray(process.env.VITE_PUBLIC, createWindow); + } + if (settings.scaleMode !== oldSettings.scaleMode) { + await resetSchedule(); + } + }); + + ipcMain.handle(Events.InitSettings, (_, settings: Settings) => { + configServiceMain.setItem('settings', settings); + }); + + ipcMain.handle(Events.SetDBItem, (_, ...args) => { + const [key, data] = args; + configServiceMain.setItem(key, data); + }); + + ipcMain.handle(Events.GetDBItem, (_, key) => { + return configServiceMain.getItem(key); + }); + + ipcMain.handle(Events.SetLiveWallpaperVolume, (_, volume) => { + setLiveWallpaperVolume(volume); + }); + + ipcMain.handle(Events.GetVersion, () => { + return app.getVersion(); + }); + + ipcMain.handle(Events.OpenExternal, (_, url) => { + shell.openExternal(url); + }); + + powerMonitor.on('resume', async () => { + await resetSchedule(); + }); +} diff --git a/electron/main/index.ts b/electron/main/index.ts new file mode 100644 index 0000000..9c3d062 --- /dev/null +++ b/electron/main/index.ts @@ -0,0 +1,179 @@ +import { app, BrowserWindow, ipcMain, Menu, shell } from 'electron'; +import { release } from 'node:os'; +import { join } from 'node:path'; +import { registerHandlers } from './handlers'; +import './locale'; +import { setTray } from './tray'; +import { resetSchedule } from './services/wallpaper'; +import { indexHtml, url } from './services/utils'; +import { + closeWallpaperWin, + createWallpaperWin, + detachWallpaperWin, +} from './services/wallpaper-window'; +import { t as _t } from 'i18next'; +import { TranslationFunc } from '../../cross/interface'; +import { update } from './update'; +import installExtension, { + REACT_DEVELOPER_TOOLS, +} from 'electron-devtools-installer'; +import { configServiceMain } from './services/db-service'; +import { WallpaperMode } from '../../cross/enums'; + +const t: TranslationFunc = _t; + +// The built directory structure +// +// ├─┬ dist-electron +// │ ├─┬ main +// │ │ └── index.js > Electron-Main +// │ └─┬ preload +// │ └── index.js > Preload-Scripts +// ├─┬ dist +// │ └── index.html > Electron-Renderer +// + +// Disable GPU Acceleration for Windows 7 +if (release().startsWith('6.1')) app.disableHardwareAcceleration(); + +// Set application name for Windows 10+ notifications +if (process.platform === 'win32') app.setAppUserModelId(app.getName()); + +if (!app.requestSingleInstanceLock()) { + app.quit(); + process.exit(0); +} + +// Remove electron security warnings +// This warning only shows in development mode +// Read more on https://www.electronjs.org/docs/latest/tutorial/security +// process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true' + +let win: BrowserWindow | null = null; +// Here, you can also use other preload +const preload = join(__dirname, '../preload/index.js'); + +const template: any[] = [ + { + label: t('help'), + role: 'help', + submenu: [ + { + label: 'Github', + click: function () { + shell.openExternal('https://github.com/Jarvay'); + }, + }, + { + label: t('debug'), + click: function () { + if (win !== null) win.webContents.openDevTools(); + }, + }, + ], + }, +]; + +async function createWindow() { + if (win) { + return win; + } + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); + + win = new BrowserWindow({ + title: 'Main window', + icon: join(process.env.VITE_PUBLIC, 'favicon.ico'), + maximizable: true, + webPreferences: { + preload, + // Warning: Enable nodeIntegration and disable contextIsolation is not secure in production + // Consider using contextBridge.exposeInMainWorld + // Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation + nodeIntegration: true, + contextIsolation: false, + webSecurity: false, + }, + }); + + win.on('closed', () => { + win = null; + }); + + if (url) { + // electron-vite-vue#298 + win.loadURL(url); + // Open devTool if the app is not packaged + win.webContents.openDevTools(); + } else { + win.loadFile(indexHtml); + } + + win.maximize(); + + // Test actively push message to the Electron-Renderer + win.webContents.on('did-finish-load', () => {}); + + // Make all links open with the browser, not with the application + win.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('https:')) shell.openExternal(url); + return { action: 'deny' }; + }); + + // Apply electron-updater + update(win); + + return win; +} + +app.whenReady().then(async () => { + registerHandlers(createWindow); + + setTray(process.env.VITE_PUBLIC, createWindow); + + await resetSchedule(); +}); + +app.on('window-all-closed', () => { + win = null; +}); + +app.on('second-instance', () => { + if (win) { + // Focus on the main window if the user tried to open another + if (win.isMinimized()) win.restore(); + win.focus(); + } +}); + +app.on('activate', () => { + const allWindows = BrowserWindow.getAllWindows(); + if (allWindows.length) { + allWindows[0].focus(); + } else { + createWindow(); + } +}); + +app.on('before-quit', () => { + detachWallpaperWin(); + closeWallpaperWin(); +}); + +// New window example arg: new windows url +ipcMain.handle('open-win', (_, arg) => { + const childWindow = new BrowserWindow({ + webPreferences: { + preload, + nodeIntegration: true, + contextIsolation: false, + }, + }); + + if (process.env.VITE_DEV_SERVER_URL) { + childWindow.loadURL(`${url}#${arg}`); + } else { + childWindow.loadFile(indexHtml, { hash: arg }); + } +}); diff --git a/electron/main/locale.ts b/electron/main/locale.ts new file mode 100644 index 0000000..b67d630 --- /dev/null +++ b/electron/main/locale.ts @@ -0,0 +1,17 @@ +import i18n from 'i18next'; +import { Locale } from '../../cross/enums'; +import { en } from '../../cross/locale/en'; +import { zhCN } from '../../cross/locale/zh-cn'; +import { configServiceMain } from './services/db-service'; + +i18n.init({ + resources: { + [Locale.enUS]: { + translation: en, + }, + [Locale.zhCN]: { + translation: zhCN, + }, + }, + lng: configServiceMain.getItem('settings')?.locale || Locale.zhCN, +}); diff --git a/electron/main/services/db-service.ts b/electron/main/services/db-service.ts new file mode 100644 index 0000000..896344c --- /dev/null +++ b/electron/main/services/db-service.ts @@ -0,0 +1,60 @@ +import { DBData, IDBService } from '../../../cross/interface'; +import { JSONSyncPreset } from 'lowdb/node'; +import { join } from 'node:path'; +import { + Locale, + MacOSScaleMode, + WallpaperMode, + WebScaleMode, + WindowsScaleMode, +} from '../../../cross/enums'; +import { LowSync } from 'lowdb'; +import { userDataDir } from './utils'; +import { platform } from 'os'; +import { merge } from 'lodash'; + +export class DBServiceMain implements IDBService { + private db: LowSync; + + constructor(db: LowSync) { + this.db = db; + } + + setItem(key: Key, data: DBData[Key]) { + this.db.data[key] = data; + this.db.write(); + } + + getItem(key: Key) { + return this.db.data[key]; + } +} + +const defaultData: DBData = { + rules: [], + weekdays: [], + settings: { + locale: Locale.zhCN, + volume: 100, + muted: true, + scaleMode: undefined, + webScaleMode: WebScaleMode.Cover, + wallpaperMode: WallpaperMode.Cover, + autoCheckUpdate: true, + openAtLogin: true, + }, + currentIndex: 0, +}; + +if (platform() === 'darwin') { + defaultData.settings.scaleMode = MacOSScaleMode.Auto; +} else if (platform() === 'win32') { + defaultData.settings.scaleMode = WindowsScaleMode.Span; +} + +const db = JSONSyncPreset(join(userDataDir, 'config.json'), defaultData); +const migratedData = merge(defaultData, db.data); +db.data = migratedData; +db.write(); + +export const configServiceMain = new DBServiceMain(db); diff --git a/electron/main/services/utils.ts b/electron/main/services/utils.ts new file mode 100644 index 0000000..9b2c455 --- /dev/null +++ b/electron/main/services/utils.ts @@ -0,0 +1,29 @@ +import { join } from 'node:path'; +import { app, ipcMain } from 'electron'; +import { mkdirSync } from 'node:fs'; + +process.env.DIST_ELECTRON = join(__dirname, '../'); +process.env.DIST = join(process.env.DIST_ELECTRON, '../dist'); +process.env.VITE_PUBLIC = process.env.VITE_DEV_SERVER_URL + ? join(process.env.DIST_ELECTRON, '../public') + : process.env.DIST; + +export const url = process.env.VITE_DEV_SERVER_URL; +export const indexHtml = join(process.env.DIST, 'index.html'); + +const userDataParentDir = !app.isPackaged + ? app.getAppPath() + : app.getPath('userData'); +export const userDataDir = join(userDataParentDir, 'userData'); + +try { + mkdirSync(userDataDir, { + recursive: true, + }); +} catch (e) {} + +export function randomByRange(min: number, max: number) { + const range = max - min; + const randNum = Math.random(); + return min + Math.round(randNum * range); +} diff --git a/electron/main/services/wallpaper-window.ts b/electron/main/services/wallpaper-window.ts new file mode 100644 index 0000000..c75d696 --- /dev/null +++ b/electron/main/services/wallpaper-window.ts @@ -0,0 +1,234 @@ +import { + BrowserWindow, + ipcMain, + screen, + IpcMainEvent, + BrowserWindowConstructorOptions, + Rectangle, + Display, +} from 'electron'; +import { platform } from 'os'; +import { Events } from '../../../cross/enums'; +import { indexHtml, url } from './utils'; +import { Subject } from 'rxjs'; +import { omit } from 'lodash'; + +const windowsMap: Map = new Map(); + +const defaultWinOptions: Electron.BrowserWindowConstructorOptions = { + autoHideMenuBar: true, + frame: false, + focusable: false, + resizable: false, + show: false, + webPreferences: { + nodeIntegration: true, + webSecurity: false, + contextIsolation: false, + }, +}; + +const subject = new Subject(); + +function loadUrl(win: BrowserWindow, displayId: number) { + let pageUrl = ''; + if (url) { + pageUrl = `${url}#/wallpaper/${displayId}`; + } else { + pageUrl = `file://${indexHtml}#/wallpaper/${displayId}`; + } + + return win.loadURL(pageUrl); +} + +async function createWindows( + displayId: number, + winOptions: (bounds: Rectangle) => BrowserWindowConstructorOptions, +) { + if (windowsMap.size === screen.getAllDisplays().length) { + return; + } + const displays = screen.getAllDisplays(); + + const loadedDisplayIdSet: Set = new Set(); + const loadedListener: (event: IpcMainEvent, ...args: any[]) => void = ( + _, + displayId: number, + ) => { + if (loadedDisplayIdSet.size === displays.length) { + ipcMain.off(Events.StaticWallpaperLoaded, loadedListener); + ipcMain.off(Events.LiveWallpaperLoaded, loadedListener); + return; + } + const win = windowsMap.get(displayId) as BrowserWindow; + + if (platform() === 'win32') { + win.maximize(); + const { attach } = require('electron-as-wallpaper'); + attach(win); + } + if (platform() === 'linux') { + win.maximize(); + } + setTimeout(() => { + win.setOpacity(1); + }, 600); + loadedDisplayIdSet.add(displayId); + }; + + ipcMain.on(Events.StaticWallpaperLoaded, loadedListener); + ipcMain.on(Events.LiveWallpaperLoaded, loadedListener); + + ipcMain.on(Events.WallpaperWinReady, (_, displayId) => { + subject.next(windowsMap.get(displayId) as BrowserWindow); + }); + + const display = screen + .getAllDisplays() + .find((item) => item.id === displayId) as Display; + const childWin = new BrowserWindow({ + ...winOptions(display.bounds), + }); + windowsMap.set(display.id, childWin); + // childWin.webContents.openDevTools(); + // childWin.setFocusable(true); + childWin.setIgnoreMouseEvents(true); + + await loadUrl(childWin, display.id); +} + +async function createDarwinWin(displayId: number) { + await createWindows(displayId, ({ width, height, x, y }) => { + return { + ...defaultWinOptions, + fullscreen: false, + type: 'desktop', + x: x - 4, + y: y - 2, + width: width + 8, + height: height + 4, + enableLargerThanScreen: true, + opacity: 0, + transparent: true, + show: true, + }; + }); +} + +async function createWin32Win(displayId: number) { + await createWindows(displayId, ({ width, height, x, y }) => { + return { + ...defaultWinOptions, + skipTaskbar: true, + show: true, + opacity: 0, + transparent: true, + x: x - 4, + y: y - 2, + width: width + 8, + height: height + 4, + }; + }); +} + +async function createLinuxWin(displayId: number) { + await createWindows(displayId, ({ width, height, x, y }) => { + return { + ...omit(defaultWinOptions, ['frame', 'focusable', 'resizable']), + type: 'desktop', + width: width, + height: height, + opacity: 0, + transparent: true, + show: true, + }; + }); +} + +export function createWallpaperWin(displayId: number) { + if (platform() === 'darwin') { + return createDarwinWin(displayId); + } else if (platform() === 'win32') { + return createWin32Win(displayId); + } else if (platform() === 'linux') { + return createLinuxWin(displayId); + } +} + +export function setLiveWallpaper(paths: string[], displayId: number) { + paths = paths.map((path) => { + if (!path.startsWith('file://')) { + return `file://${path}`; + } + return path; + }); + + subject.subscribe({ + next: (value) => { + const win = windowsMap.get(displayId); + if (win === value) { + value?.webContents.send(Events.SetLiveWallpaper, paths); + } + }, + }); + + windowsMap.forEach((win, dId) => { + if (displayId === dId) { + win.webContents.send(Events.SetLiveWallpaper, paths); + } + }); + + return createWallpaperWin(displayId); +} + +export function setStaticWallpaper(path: string, displayId: number) { + subject.subscribe({ + next: (value) => { + const win = windowsMap.get(displayId); + if (win === value) { + value?.webContents.send(Events.SetStaticWallpaper, path); + } + }, + }); + + windowsMap.forEach((win, dId) => { + if (displayId === dId) { + win.webContents.send(Events.SetStaticWallpaper, path); + } + }); + + return createWallpaperWin(displayId); +} + +export function detachWallpaperWin() { + windowsMap.forEach((win) => { + if (platform() === 'win32') { + const { detach } = require('electron-as-wallpaper'); + detach(win); + } + win.hide(); + }); +} + +export function closeWallpaperWin() { + windowsMap.forEach((win) => { + win.close(); + win.destroy(); + if (platform() === 'win32') { + const { refresh } = require('electron-as-wallpaper'); + refresh(); + } + }); +} + +export function setLiveWallpaperMuted(muted: boolean) { + windowsMap.forEach((win) => { + win?.webContents.send(Events.SetLiveWallpaperMuted, muted); + }); +} + +export function setLiveWallpaperVolume(volume: number) { + windowsMap.forEach((win) => { + win.webContents.send(Events.SetLiveWallpaperVolume, volume); + }); +} diff --git a/electron/main/services/wallpaper.ts b/electron/main/services/wallpaper.ts new file mode 100644 index 0000000..719ec9e --- /dev/null +++ b/electron/main/services/wallpaper.ts @@ -0,0 +1,223 @@ +import { readdirSync } from 'fs'; +import { join } from 'node:path'; +import { IMAGE_EXT_LIST, VIDEO_EXT_LIST } from '../../../cross/consts'; +import { Rule } from '../../../cross/interface'; +import { ChangeType, WallpaperMode, WallpaperType } from '../../../cross/enums'; +import { + detachWallpaperWin, + setLiveWallpaper, + setStaticWallpaper, +} from './wallpaper-window'; +import { gracefulShutdown, RecurrenceRule, scheduleJob } from 'node-schedule'; +import { timeToSeconds } from '../../../cross/date'; +import dayjs from 'dayjs'; +import { configServiceMain } from './db-service'; +import { SetOptions } from 'wallpaper'; +import { randomByRange } from './utils'; +import { screen } from 'electron'; +import { platform } from 'os'; + +const timerMap: Map = new Map(); + +const typeExtMap = new Map([ + [ + WallpaperType.Image, + IMAGE_EXT_LIST.filter((ext) => { + return !(platform() === 'linux' && ext === 'heic'); + }), + ], + [WallpaperType.Video, VIDEO_EXT_LIST], +]); + +export async function setWallpaper( + rule: Rule, + filePath: string, + displayId: number, + currentIndex: number, +) { + switch (rule.wallpaperType) { + case WallpaperType.Image: + const { wallpaperMode, scaleMode } = + configServiceMain.getItem('settings'); + const wallpaper = await import('wallpaper'); + if (wallpaperMode === WallpaperMode.Replace) { + await wallpaper.setWallpaper(filePath, { + scale: scaleMode as SetOptions['scale'], + screen: 'all', + }); + detachWallpaperWin(); + } else { + await setStaticWallpaper(filePath, displayId); + } + configServiceMain.setItem('currentIndex', currentIndex); + break; + case WallpaperType.Video: + await setLiveWallpaper([filePath], displayId); + break; + } +} + +export async function updateWallpaper(rule: Rule, currentIndex: number) { + const extList = typeExtMap.get(rule.wallpaperType) as string[]; + if (rule.type !== ChangeType.Fixed && rule.isRandom) { + const max = readRuleFilePaths(rule, extList).length - 1; + currentIndex = randomByRange(0, max); + } + + let filePath = readRuleFilePaths(rule, extList)[currentIndex]; + + const displays = screen.getAllDisplays(); + for (const display of displays) { + if (rule.type !== ChangeType.Fixed && rule.screenRandom) { + const max = readRuleFilePaths(rule, extList).length - 1; + currentIndex = randomByRange(0, max); + filePath = readRuleFilePaths(rule, extList)[currentIndex]; + } + await setWallpaper(rule, filePath, display.id, currentIndex); + } +} + +function increaseImgIndex(currentIndex: number, rule: Rule) { + const total = readRuleFilePaths(rule, IMAGE_EXT_LIST).length; + + if (currentIndex + 1 === total) { + return 0; + } + return currentIndex + 1; +} + +function readRuleFilePaths(rule: Rule, extList: string[]) { + const filePaths = readdirSync(rule.path) || []; + return filePaths + .filter((item) => !/(^|\/)\.[^\/.]/g.test(item)) + .filter((item) => extList.some((ext) => item.endsWith(ext))) + .map((item) => { + return join(rule.path, item); + }); +} + +export async function createWallpaperTimer(rule: Rule) { + let currentIndex = increaseImgIndex( + configServiceMain.getItem('currentIndex'), + rule, + ); + await updateWallpaper(rule, currentIndex); + return setInterval( + async () => { + currentIndex = increaseImgIndex(currentIndex, rule); + await updateWallpaper(rule, currentIndex); + }, + (rule.interval || 60) * 1000, + ); +} + +function isCurrentRule(rule: Rule, day: number) { + let dayOfWeek = new Date().getDay(); + dayOfWeek = dayOfWeek || 7; + if (dayOfWeek !== day) { + return false; + } + + const now = timeToSeconds(dayjs().format('HH:mm')); + const start = timeToSeconds(rule.start); + const end = timeToSeconds(rule.end); + return start <= now && end >= now; +} + +function getWeekdayById(id: string) { + return configServiceMain.getItem('weekdays').find((item) => { + return item.id === id; + }); +} + +export async function resetSchedule() { + await gracefulShutdown(); + + for (const timerMapElement of timerMap) { + const [id, timer] = timerMapElement; + clearInterval(timer); + } + timerMap.clear(); + + const rules = configServiceMain.getItem('rules'); + + rules.forEach((rule) => { + const [startHour, startMinute] = rule.start.split(':'); + const [endHour, endMinute] = rule.end.split(':'); + + async function createIntervalPlan(dayOfWeek: number) { + clearInterval(timerMap.get(rule.id as string)); + timerMap.set(rule.id as string, await createWallpaperTimer(rule)); + + const stopRule = new RecurrenceRule(); + stopRule.second = 59; + stopRule.minute = endMinute; + stopRule.hour = endHour; + stopRule.dayOfWeek = dayOfWeek; + scheduleJob(stopRule, () => { + clearInterval(timerMap.get(rule.id as string)); + }); + } + + const weekday = getWeekdayById(rule.weekdayId as string); + const days = weekday?.days || []; + days.forEach((day) => { + const isCurrent = isCurrentRule(rule, day); + + const jobRule = new RecurrenceRule(); + jobRule.second = 0; + jobRule.minute = startMinute; + jobRule.hour = startHour; + jobRule.dayOfWeek = day; + + function setFixedWallpaper() { + let index = 0; + for (const display of screen.getAllDisplays()) { + const path = rule.paths?.[index] || rule.paths?.[0]; + setWallpaper(rule, path, display.id, 0); + index++; + } + } + + function setAutoChangeLiveWallpapers() { + let index = 0; + for (const display of screen.getAllDisplays()) { + setLiveWallpaper(readRuleFilePaths(rule, VIDEO_EXT_LIST), display.id); + index++; + } + } + + switch (rule.type) { + default: + case ChangeType.Fixed: + if (isCurrent) { + setFixedWallpaper(); + } else { + scheduleJob(jobRule, () => { + setFixedWallpaper(); + }); + } + break; + case ChangeType.AutoChange: + if (rule.wallpaperType === WallpaperType.Image) { + if (isCurrent) { + createIntervalPlan(day); + } else { + scheduleJob(jobRule, () => { + createIntervalPlan(day); + }); + } + } else if (rule.wallpaperType === WallpaperType.Video) { + if (isCurrent) { + setAutoChangeLiveWallpapers(); + } else { + scheduleJob(jobRule, () => { + setAutoChangeLiveWallpapers(); + }); + } + } + break; + } + }); + }); +} diff --git a/electron/main/tray.ts b/electron/main/tray.ts new file mode 100644 index 0000000..a8da766 --- /dev/null +++ b/electron/main/tray.ts @@ -0,0 +1,118 @@ +import { platform } from 'os'; +import { app, BrowserWindow, Menu, Tray } from 'electron'; +import { join } from 'node:path'; +import { t as _t } from 'i18next'; +import { TranslationFunc } from '../../cross/interface'; +import { configServiceMain } from './services/db-service'; +import { setLiveWallpaperMuted } from './services/wallpaper-window'; + +const t: TranslationFunc = _t; + +let tray: Tray; + +enum TrayMenuItem { + OpenAtLogin = 1, + Muted = 2, + AutoCheckUpdate = 4, +} + +export function setTray( + publicDir: string, + createWindow: () => Promise, +) { + const trayIcon = + platform() === 'win32' ? 'favicon.ico' : 'faviconTemplate.png'; + if (!tray) { + tray = new Tray(join(publicDir, trayIcon)); + } + + tray.on('double-click', () => { + createWindow(); + }); + + const openAtLogin = configServiceMain.getItem('settings').openAtLogin; + if (app.isPackaged) { + app.setLoginItemSettings({ + openAtLogin, + }); + } + + const contextMenu = Menu.buildFromTemplate([ + { + label: t('showWindow'), + type: 'normal', + click: () => { + createWindow(); + }, + }, + { + label: t('startAtLogin'), + type: 'checkbox', + checked: openAtLogin, + click: () => { + if (app.isPackaged) { + const newOpenAtLogin = + !configServiceMain.getItem('settings').openAtLogin; + app.setLoginItemSettings({ + openAtLogin: newOpenAtLogin, + }); + contextMenu.items[TrayMenuItem.OpenAtLogin].checked = newOpenAtLogin; + tray?.setContextMenu(contextMenu); + configServiceMain.setItem('settings', { + ...configServiceMain.getItem('settings'), + openAtLogin: newOpenAtLogin, + }); + } + }, + }, + { + label: t('settings.mute'), + type: 'checkbox', + checked: configServiceMain.getItem('settings').muted, + click: () => { + const muted = !configServiceMain.getItem('settings').muted; + contextMenu.items[TrayMenuItem.Muted].checked = muted; + tray?.setContextMenu(contextMenu); + setLiveWallpaperMuted(muted); + configServiceMain.setItem('settings', { + ...configServiceMain.getItem('settings'), + muted, + }); + }, + }, + { + label: t('checkUpdate'), + type: 'normal', + click: async () => { + await createWindow(); + }, + }, + { + label: t('autoCheckUpdate'), + type: 'checkbox', + checked: configServiceMain.getItem('settings').autoCheckUpdate, + click: () => { + const autoCheckUpdate = + !configServiceMain.getItem('settings').autoCheckUpdate; + contextMenu.items[TrayMenuItem.AutoCheckUpdate].checked = + autoCheckUpdate; + tray?.setContextMenu(contextMenu); + setLiveWallpaperMuted(autoCheckUpdate); + configServiceMain.setItem('settings', { + ...configServiceMain.getItem('settings'), + autoCheckUpdate, + }); + }, + }, + { + label: t('exit'), + type: 'normal', + click: () => { + app.quit(); + }, + }, + ]); + tray.setContextMenu(contextMenu); + + return tray; +} diff --git a/electron/main/update.ts b/electron/main/update.ts new file mode 100644 index 0000000..d363c9e --- /dev/null +++ b/electron/main/update.ts @@ -0,0 +1,86 @@ +import { app, ipcMain } from 'electron'; +import { + type ProgressInfo, + type UpdateDownloadedEvent, + autoUpdater, +} from 'electron-updater'; + +export function update(win: Electron.BrowserWindow) { + // When set to false, the update download will be triggered through the API + autoUpdater.autoDownload = false; + autoUpdater.disableWebInstaller = false; + autoUpdater.allowDowngrade = false; + + // start check + autoUpdater.on('checking-for-update', function () { + console.info('checking-for-update'); + }); + // update available + autoUpdater.on('update-available', (arg) => { + console.log('update-available', arg); + win.webContents.send('update-can-available', { + update: true, + version: app.getVersion(), + newVersion: arg?.version, + }); + }); + // update not available + autoUpdater.on('update-not-available', (arg) => { + console.log('update-not-available', arg); + win.webContents.send('update-can-available', { + update: false, + version: app.getVersion(), + newVersion: arg?.version, + }); + }); + + // Checking for updates + ipcMain.handle('check-update', async () => { + if (!app.isPackaged) { + const error = new Error( + 'The update feature is only available after the package.', + ); + return { message: error.message, error }; + } + + try { + return await autoUpdater.checkForUpdatesAndNotify(); + } catch (error) { + return { message: 'Network error', error }; + } + }); + + // Start downloading and feedback on progress + ipcMain.handle('start-download', (event) => { + startDownload( + (error, progressInfo) => { + if (error) { + // feedback download error message + event.sender.send('update-error', { message: error.message, error }); + } else { + // feedback update progress message + event.sender.send('download-progress', progressInfo); + } + }, + () => { + // feedback update downloaded message + event.sender.send('update-downloaded'); + }, + ); + }); + + // Install now + ipcMain.handle('quit-and-install', () => { + autoUpdater.quitAndInstall(false, true); + }); +} + +function startDownload( + callback: (error: Error | null, info: ProgressInfo | null) => void, + complete: (event: UpdateDownloadedEvent) => void, +) { + autoUpdater.on('download-progress', (info) => callback(null, info)); + autoUpdater.on('error', (error) => callback(error, null)); + autoUpdater.on('update-downloaded', complete); + autoUpdater.downloadUpdate(); +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts new file mode 100644 index 0000000..ebd1e48 --- /dev/null +++ b/electron/preload/index.ts @@ -0,0 +1,94 @@ +function domReady( + condition: DocumentReadyState[] = ['complete', 'interactive'], +) { + return new Promise((resolve) => { + if (condition.includes(document.readyState)) { + resolve(true); + } else { + document.addEventListener('readystatechange', () => { + if (condition.includes(document.readyState)) { + resolve(true); + } + }); + } + }); +} + +const safeDOM = { + append(parent: HTMLElement, child: HTMLElement) { + if (!Array.from(parent.children).find((e) => e === child)) { + return parent.appendChild(child); + } + }, + remove(parent: HTMLElement, child: HTMLElement) { + if (Array.from(parent.children).find((e) => e === child)) { + return parent.removeChild(child); + } + }, +}; + +/** + * https://tobiasahlin.com/spinkit + * https://connoratherton.com/loaders + * https://projects.lukehaas.me/css-loaders + * https://matejkustec.github.io/SpinThatShit + */ +function useLoading() { + const className = `loaders-css__square-spin`; + const styleContent = ` +@keyframes square-spin { + 25% { transform: perspective(100px) rotateX(180deg) rotateY(0); } + 50% { transform: perspective(100px) rotateX(180deg) rotateY(180deg); } + 75% { transform: perspective(100px) rotateX(0) rotateY(180deg); } + 100% { transform: perspective(100px) rotateX(0) rotateY(0); } +} +.${className} > div { + animation-fill-mode: both; + width: 50px; + height: 50px; + background: #fff; + animation: square-spin 3s 0s cubic-bezier(0.09, 0.57, 0.49, 0.9) infinite; +} +.app-loading-wrap { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #282c34; + z-index: 9; +} + `; + const oStyle = document.createElement('style'); + const oDiv = document.createElement('div'); + + oStyle.id = 'app-loading-style'; + oStyle.innerHTML = styleContent; + oDiv.className = 'app-loading-wrap'; + oDiv.innerHTML = `
`; + + return { + appendLoading() { + safeDOM.append(document.head, oStyle); + safeDOM.append(document.body, oDiv); + }, + removeLoading() { + safeDOM.remove(document.head, oStyle); + safeDOM.remove(document.body, oDiv); + }, + }; +} + +// ---------------------------------------------------------------------- + +const { appendLoading, removeLoading } = useLoading(); +domReady().then(appendLoading); + +window.onmessage = (ev) => { + ev.data.payload === 'removeLoading' && removeLoading(); +}; + +setTimeout(removeLoading, 4999); diff --git a/index.html b/index.html new file mode 100644 index 0000000..ba93854 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + + Workpaper + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..4eedb4a --- /dev/null +++ b/package.json @@ -0,0 +1,68 @@ +{ + "name": "workpaper", + "version": "0.0.1", + "main": "dist-electron/main/index.js", + "license": "MIT", + "private": true, + "debug": { + "env": { + "VITE_DEV_SERVER_URL": "http://127.0.0.1:7777/" + } + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build && electron-builder", + "preview": "vite preview", + "pree2e": "vite build --mode=test", + "e2e": "playwright test" + }, + "dependencies": { + "electron-as-wallpaper": "1.0.3", + "electron-updater": "^6.1.1", + "wallpaper": "^7.2.1" + }, + "devDependencies": { + "@ant-design/icons": "^5.2.6", + "@playwright/test": "^1.37.1", + "@types/lodash": "^4.14.200", + "@types/node-schedule": "^2.1.2", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@vitejs/plugin-react": "^4.0.4", + "ahooks": "^3.7.8", + "animate.css": "^4.1.1", + "antd": "^5.10.2", + "autoprefixer": "^10.4.16", + "dayjs": "^1.11.10", + "electron": "29.0.0-alpha.5", + "electron-builder": "^24.6.3", + "electron-devtools-installer": "^3.2.0", + "i18next": "^23.6.0", + "less": "^4.2.0", + "less-loader": "^11.1.3", + "lodash": "^4.17.21", + "lowdb": "^6.1.1", + "mitt": "^3.0.1", + "node-schedule": "^2.1.1", + "postcss": "^8.4.31", + "prettier": "^3.0.3", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-i18next": "^13.3.1", + "react-router-dom": "^6.18.0", + "rxjs": "^7.8.1", + "tailwindcss": "^3.3.3", + "typescript": "^5.1.6", + "vite": "5.0.5", + "vite-plugin-electron": "^0.13.0-beta.3", + "vite-plugin-electron-renderer": "^0.14.5", + "vite-plugin-esmodule": "^1.5.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "repository": { + "type": "git", + "url": "git@github.com:Jarvay/workpaper.git" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d323551 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,54 @@ +import type { PlaywrightTestConfig } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: "./e2e", + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // port: 3000, + // }, +}; + +export default config; diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000..825cb18 --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + 'postcss-import': {}, + 'tailwindcss/nesting': {}, + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..906d24a Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/faviconTemplate.png b/public/faviconTemplate.png new file mode 100644 index 0000000..62aa419 Binary files /dev/null and b/public/faviconTemplate.png differ diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..22615e1 --- /dev/null +++ b/src/App.css @@ -0,0 +1,55 @@ +#root { + width: 100%; + height: 100vh; + margin: 0 auto; + text-align: center; + box-sizing: border-box; + overflow-y: hidden; +} + +.logo-box { + position: relative; + height: 9em; +} + +.logo { + position: absolute; + left: calc(50% - 4.5em); + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} + +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + .logo.electron { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..6b1a617 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,93 @@ +import './App.css'; +import { ConfigProvider, FloatButton } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; +import enUS from 'antd/locale/en_US'; +import { RouterProvider } from 'react-router-dom'; +import { router } from '@/router'; +import { SettingOutlined } from '@ant-design/icons'; +import SettingsModal from '@/components/SettingsModal'; +import { useCallback, useState } from 'react'; +import { Locale } from '../cross/enums'; +import { ConfigProviderProps } from 'antd/es/config-provider'; +import { settingsService } from '@/services/settings'; +import { useMount, useUnmount } from 'ahooks'; +import { emitter } from '@/services/emitter'; +import { Settings } from '../cross/interface'; +import { ipcRenderer } from 'electron'; + +const LOCALE_MAP = new Map([ + [Locale.zhCN, zhCN], + [Locale.enUS, enUS], +]); + +function App() { + const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsBtnShow, setSettingsBtnShow] = useState(true); + const [updateAvailable, setUpdateAvailable] = useState(false); + const [versionInfo, setVersionInfo] = useState(); + + const [locale, setLocale] = useState(); + + const onUpdateCanAvailable = useCallback( + (_event: Electron.IpcRendererEvent, arg1: VersionInfo) => { + if (arg1.update) { + setUpdateAvailable(true); + setVersionInfo(arg1); + } + }, + [], + ); + + useMount(async () => { + emitter.on('setSettingsBtnShow', (visible: boolean) => { + setSettingsBtnShow(visible); + }); + + const settings = await settingsService.get(); + setLocale(LOCALE_MAP.get(settings.locale)); + + if (window.location.hash.includes('/wallpaper')) { + ipcRenderer.on('update-can-available', onUpdateCanAvailable); + if (settings.autoCheckUpdate) { + try { + await ipcRenderer.invoke('check-update'); + } catch (e) { + console.warn(e); + } + } + } + }); + + useUnmount(() => { + ipcRenderer.off('update-can-available', onUpdateCanAvailable); + }); + + return ( + + + + <> + {settingsBtnShow && ( + } + badge={updateAvailable ? { dot: true } : undefined} + onClick={() => { + setSettingsOpen(true); + }} + /> + )} + + setSettingsOpen(false)} + onChange={(settings) => { + setLocale(LOCALE_MAP.get(settings?.locale as Settings['locale'])); + }} + versionInfo={versionInfo} + /> + + + ); +} + +export default App; diff --git a/src/components/CenterTable/index.tsx b/src/components/CenterTable/index.tsx new file mode 100644 index 0000000..fed8b95 --- /dev/null +++ b/src/components/CenterTable/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Table } from 'antd'; +import type { TableProps } from 'antd'; +import { AnyObject } from 'antd/es/_util/type'; + +const CenterTable = (props: TableProps) => { + if (Array.isArray(props.columns)) { + props.columns.forEach((column) => { + column.align = 'center'; + }); + } + + return ; +}; + +export default CenterTable; diff --git a/src/components/PageContainer/index.module.less b/src/components/PageContainer/index.module.less new file mode 100644 index 0000000..82f0d3a --- /dev/null +++ b/src/components/PageContainer/index.module.less @@ -0,0 +1,3 @@ +.app-layout { + padding: 2em; +} diff --git a/src/components/PageContainer/index.tsx b/src/components/PageContainer/index.tsx new file mode 100644 index 0000000..b094c5c --- /dev/null +++ b/src/components/PageContainer/index.tsx @@ -0,0 +1,11 @@ +import React, { ReactNode } from 'react'; +import styles from './index.module.less'; + +export interface PageContainerProps { + children?: React.JSX.Element | ReactNode; +} +const PageContainer: React.FC = (props) => { + return
{props.children}
; +}; + +export default PageContainer; diff --git a/src/components/ScaleModeComponent/index.tsx b/src/components/ScaleModeComponent/index.tsx new file mode 100644 index 0000000..0e1ac80 --- /dev/null +++ b/src/components/ScaleModeComponent/index.tsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; +import { DefaultOptionType } from 'rc-select/lib/Select'; +import { useTranslation } from 'react-i18next'; +import { useMount } from 'ahooks'; +import { ipcRenderer } from 'electron'; +import { + Events, + MacOSScaleMode, + WallpaperMode, + WebScaleMode, + WindowsScaleMode, +} from '../../../cross/enums'; +import { Settings, TranslationFunc } from '../../../cross/interface'; +import { settingsService } from '@/services/settings'; + +export interface ScaleModeComponentProps { + children?: ( + scaleModeOptions: DefaultOptionType[], + ) => React.ReactNode | JSX.Element; +} + +const ScaleModeComponent: React.FC = (props) => { + const { t }: { t: TranslationFunc } = useTranslation(); + + const [platform, setPlatform] = useState(); + const [settings, setSettings] = useState(); + + useMount(async () => { + setPlatform(await ipcRenderer.invoke(Events.GetPlatform)); + + setSettings(await settingsService.get()); + }); + + let scaleModeOptions: DefaultOptionType[] = []; + + if (settings?.wallpaperMode === WallpaperMode.Cover) { + scaleModeOptions = [ + { label: t('scaleMode.default'), value: undefined }, + { label: t('webScaleMode.fill'), value: WebScaleMode.Fill }, + { label: t('webScaleMode.contain'), value: WebScaleMode.Contain }, + { label: t('webScaleMode.cover'), value: WebScaleMode.Cover }, + ]; + } else { + switch (platform) { + default: + case 'win32': + scaleModeOptions = [ + { label: t('scaleMode.default'), value: undefined }, + { label: t('scaleMode.fit'), value: WindowsScaleMode.Fit }, + { label: t('scaleMode.center'), value: WindowsScaleMode.Center }, + { label: t('scaleMode.stretch'), value: WindowsScaleMode.Stretch }, + { label: t('scaleMode.fill'), value: WindowsScaleMode.Fill }, + { label: t('scaleMode.tile'), value: WindowsScaleMode.Tile }, + { label: t('scaleMode.span'), value: WindowsScaleMode.Span }, + ]; + break; + case 'darwin': + scaleModeOptions = [ + { label: t('scaleMode.default'), value: undefined }, + { label: t('scaleMode.fit'), value: MacOSScaleMode.Fit }, + { label: t('scaleMode.center'), value: MacOSScaleMode.Center }, + { label: t('scaleMode.stretch'), value: MacOSScaleMode.Stretch }, + { label: t('scaleMode.fill'), value: MacOSScaleMode.Fill }, + { label: t('scaleMode.auto'), value: MacOSScaleMode.Auto }, + ]; + break; + } + } + + return props.children?.(scaleModeOptions) || null; +}; + +export default ScaleModeComponent; diff --git a/src/components/SettingsModal/index.module.less b/src/components/SettingsModal/index.module.less new file mode 100644 index 0000000..c95d684 --- /dev/null +++ b/src/components/SettingsModal/index.module.less @@ -0,0 +1,3 @@ +.form-item { + width: 200px !important; +} diff --git a/src/components/SettingsModal/index.tsx b/src/components/SettingsModal/index.tsx new file mode 100644 index 0000000..e37d258 --- /dev/null +++ b/src/components/SettingsModal/index.tsx @@ -0,0 +1,180 @@ +import React, { useState } from 'react'; +import { Badge, Button, Form, Modal, Select, Slider, Space } from 'antd'; +import { + ModalFormProps, + Settings, + TranslationFunc, +} from '../../../cross/interface'; +import { debounce, omit } from 'lodash'; +import { useMount, useUpdateEffect } from 'ahooks'; +import { settingsService } from '@/services/settings'; +import { Events, Locale, WallpaperMode } from '../../../cross/enums'; +import { useTranslation } from 'react-i18next'; +import ScaleModeComponent from '@/components/ScaleModeComponent'; +import styles from './index.module.less'; +import { ipcRenderer } from 'electron'; +import Update from '@/components/Update'; + +export interface SettingsModalProps extends ModalFormProps { + versionInfo?: VersionInfo; +} + +const SettingsModal: React.FC = (props) => { + const [form] = Form.useForm(); + const [version, setVersion] = useState(''); + const [versionInfo, setVersionInfo] = useState(); + const [settings, setSettings] = useState(); + const [platform, setPlatform] = useState(); + + const { t }: { t: TranslationFunc } = useTranslation(); + + async function getVersion() { + const ver = await ipcRenderer.invoke(Events.GetVersion); + setVersion(ver); + } + + useMount(async () => { + setPlatform(await ipcRenderer.invoke(Events.GetPlatform)); + }); + + useUpdateEffect(() => { + if (props.open) { + settingsService.get().then((settings) => { + setSettings(settings); + form.resetFields(); + form.setFieldsValue({ + ...settings, + }); + }); + + getVersion(); + + if (props.versionInfo) { + setVersionInfo(props.versionInfo); + } + } + }, [props.open]); + + return ( + + props.onCancel?.( + event as React.MouseEvent, + ) + } + > + {t('close')} + + } + destroyOnClose + title={t('settings')} + > +
{ + await settingsService.save(values); + await props.onChange?.(values as Settings); + }} + > + + + + + + {({ getFieldsValue }) => { + const { wallpaperMode } = getFieldsValue() as Settings; + if ( + wallpaperMode === WallpaperMode.Replace && + platform === 'linux' + ) { + return null; + } + return ( + + {(scaleModeOptions) => { + const name = + settings?.wallpaperMode === WallpaperMode.Cover + ? 'webScaleMode' + : 'scaleMode'; + return ( + + + + +
+ ); +}; + +export default WallpaperRuleModal; diff --git a/src/pages/rule/index.tsx b/src/pages/rule/index.tsx new file mode 100644 index 0000000..075778b --- /dev/null +++ b/src/pages/rule/index.tsx @@ -0,0 +1,258 @@ +import React, { useState } from 'react'; +import { ruleService } from '@/services/rule'; +import { useMount, useUnmount } from 'ahooks'; +import { ipcRenderer } from 'electron'; +import { + ChangeType, + Events, + FormMode, + WallpaperType, +} from '../../../cross/enums'; +import { ColumnsType } from 'antd/es/table/InternalTable'; +import { Button, Image, Popconfirm, Space } from 'antd'; +import WallpaperRule from './components/WallpaperRuleModal'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ArrowLeftOutlined } from '@ant-design/icons'; +import { weekdayService } from '@/services/weekday'; +import { Rule, TranslationFunc, Weekday } from '../../../cross/interface'; +import { useTranslation } from 'react-i18next'; +import WeekComponent from '@/components/WeekComponent'; +import PageContainer from '@/components/PageContainer'; +import CenterTable from '@/components/CenterTable'; + +const RuleIndex: React.FC = () => { + const [dataSource, setDataSource] = useState([]); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [updateModalOpen, setUpdateModalOpen] = useState(false); + const [currentRow, setCurrentRow] = useState(); + const [weekday, setWeekday] = useState(); + + const navigate = useNavigate(); + const { id: weekdayId } = useParams(); + + const { t }: { t: TranslationFunc } = useTranslation(); + + async function refresh() { + const rules = await ruleService.get(); + setDataSource(rules.filter((rule) => rule.weekdayId === weekdayId)); + } + + useMount(async () => { + refresh(); + setWeekday( + (await weekdayService.get()).find((item) => item.id === weekdayId), + ); + }); + + useUnmount(() => { + ipcRenderer.removeAllListeners(Events.ResetSchedule); + }); + + const columns: ColumnsType = [ + { + title: t('rule.timeSlot'), + dataIndex: 'time', + width: 180, + render: (value, record) => { + return ( + + {record.start} - {record.end} + + ); + }, + }, + { + title: t('rule.period'), + dataIndex: 'days', + width: 200, + render: (value, record) => { + return ( + + {(weekMap) => { + return weekday?.days?.map((day) => weekMap.get(day)).join(', '); + }} + + ); + }, + }, + { + title: t('rule.wallpaperType'), + dataIndex: 'wallpaperType', + width: 120, + render: (value) => { + switch (value) { + default: + case WallpaperType.Image: + return t('rule.wallpaperType.image'); + case WallpaperType.Video: + return t('rule.wallpaperType.video'); + } + }, + }, + { + title: t('rule.type'), + dataIndex: 'type', + width: 120, + render: (value) => { + switch (value) { + default: + case ChangeType.Fixed: + return t('rule.type.fixed'); + case ChangeType.AutoChange: + return t('rule.type.autoChange'); + } + }, + }, + { + title: t('rule.path'), + dataIndex: 'path', + width: 250, + ellipsis: true, + render: (value, record) => { + switch (record.type) { + default: + case ChangeType.Fixed: + switch (record.wallpaperType) { + default: + case WallpaperType.Image: + return ( + + {record.paths?.map((item) => { + return ( + + ); + })} + + ); + case WallpaperType.Video: + return ( + + {record.paths?.map((item) => { + return ( + + ); + } + + case ChangeType.AutoChange: + return {value}; + } + }, + }, + { + title: t('rule.interval'), + dataIndex: 'interval', + width: 120, + render: (value, record) => { + if (record.wallpaperType === WallpaperType.Video) { + return '-'; + } + return value || '-'; + }, + }, + { + title: t('rule.isRandom'), + dataIndex: 'isRandom', + width: 120, + render: (value) => { + return value ? t('yes') : t('no'); + }, + }, + { + title: t('operation'), + dataIndex: 'options', + width: 180, + fixed: 'right', + render: (value, record) => { + return ( + + { + setCurrentRow(record); + setUpdateModalOpen(true); + }} + > + {t('edit')} + + + { + await ruleService.delete(record.id as string); + refresh(); + }} + > + {t('delete')} + + + ); + }, + }, + ]; + + return ( + + +
+ + + + + setCreateModalOpen(false)} + onChange={async () => { + setCreateModalOpen(false); + refresh(); + }} + /> +
+ + + + setUpdateModalOpen(false)} + values={currentRow} + mode={FormMode.Update} + onChange={() => { + setUpdateModalOpen(false); + refresh(); + }} + /> +
+
+ ); +}; + +export default RuleIndex; diff --git a/src/pages/wallpaper/components/LiveWallpaper/index.module.less b/src/pages/wallpaper/components/LiveWallpaper/index.module.less new file mode 100644 index 0000000..2fbcf05 --- /dev/null +++ b/src/pages/wallpaper/components/LiveWallpaper/index.module.less @@ -0,0 +1,7 @@ +.live-wallpaper-container { + position: absolute; + left: 0; + top: 0; + min-width: 100vw; + min-height: 100vh; +} diff --git a/src/pages/wallpaper/components/LiveWallpaper/index.tsx b/src/pages/wallpaper/components/LiveWallpaper/index.tsx new file mode 100644 index 0000000..6738000 --- /dev/null +++ b/src/pages/wallpaper/components/LiveWallpaper/index.tsx @@ -0,0 +1,102 @@ +import React, { LegacyRef, useRef, useState } from 'react'; +import { ipcRenderer, type IpcRendererEvent } from 'electron'; +import { Events, WallpaperType } from '../../../../../cross/enums'; +import { useParams } from 'react-router-dom'; +import { useMount, useUnmount, useUpdateEffect } from 'ahooks'; +import styles from './index.module.less'; + +export interface LiveWallpaperProps { + style?: React.CSSProperties; +} + +const LiveWallpaper: React.FC = (props) => { + const [paths, setPaths] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const { displayId } = useParams(); + + const videoRef = useRef(); + + const liveWallpaperHandler: ( + event: IpcRendererEvent, + ...args: any[] + ) => void = (_, _paths: string[]) => { + setPaths(_paths); + setCurrentIndex(0); + }; + + const liveWallpaperMutedHandler: ( + event: IpcRendererEvent, + ...args: any[] + ) => void = (_, muted: boolean) => { + if (videoRef.current) { + videoRef.current.muted = muted; + } + }; + + const liveWallpaperVolumeHandler: ( + event: IpcRendererEvent, + ...args: any[] + ) => void = (_, volume: number) => { + if (videoRef.current) { + videoRef.current.volume = volume; + } + }; + + function registerLiveWallpaperEvents() { + ipcRenderer.on(Events.SetLiveWallpaper, liveWallpaperHandler); + + ipcRenderer.on(Events.SetLiveWallpaperMuted, liveWallpaperMutedHandler); + + ipcRenderer.on(Events.SetLiveWallpaperVolume, liveWallpaperVolumeHandler); + } + + function unregisterLiveWallpaperEvents() { + ipcRenderer.off(Events.SetLiveWallpaper, liveWallpaperHandler); + + ipcRenderer.off(Events.SetLiveWallpaperMuted, liveWallpaperMutedHandler); + + ipcRenderer.off(Events.SetLiveWallpaperVolume, liveWallpaperVolumeHandler); + } + + useMount(() => { + registerLiveWallpaperEvents(); + }); + + useUnmount(() => { + unregisterLiveWallpaperEvents(); + }); + + useUpdateEffect(() => { + if (!videoRef.current) return; + videoRef.current.src = paths[currentIndex]; + videoRef.current.load(); + videoRef.current.play(); + }, [currentIndex]); + + return ( +