├── .github ├── dependabot.yml └── workflows │ └── node.js.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── build └── icons │ ├── 128x128.png │ ├── 16x16.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 512x512.png │ └── 64x64.png ├── cli ├── src │ ├── bashly.yml │ ├── daemon_command.sh │ ├── history_command.sh │ ├── info_command.sh │ ├── info_playlist_command.sh │ ├── kill_daemon_command.sh │ ├── next_image_command.sh │ ├── pause_playlist_command.sh │ ├── playlist_command.sh │ ├── previous_image_command.sh │ ├── random_command.sh │ ├── resume_playlist_command.sh │ ├── run_command.sh │ ├── start_playlist_command.sh │ ├── stop_daemon_command.sh │ └── stop_playlist_command.sh └── waypaper-engine ├── daemon ├── daemon.ts ├── daemonManager.ts ├── package-lock.json ├── package.json └── playlist.ts ├── database ├── database.ts ├── dbOperations.ts ├── migrations │ ├── 0000_harsh_siren.sql │ ├── 0001_flat_champions.sql │ ├── 0002_gray_kylun.sql │ └── meta │ │ ├── 0000_snapshot.json │ │ ├── 0001_snapshot.json │ │ ├── 0002_snapshot.json │ │ └── _journal.json └── schema.ts ├── drizzle.config.ts ├── electron-builder.json ├── electron-builder_AppImage.json ├── electron ├── appFunctions.ts ├── electron-env.d.ts ├── exposedApi.ts ├── main.ts ├── playlistController.ts └── preload.ts ├── eslint.config.mjs ├── globals ├── config.ts ├── menus.ts ├── setup.ts └── startDaemons.ts ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── app.png ├── readme_files ├── Waypaper_Engine.png ├── app_settings.png ├── gallery.png ├── sidebar.png └── swww_settings.png ├── shared ├── constants.ts ├── types.ts └── types │ ├── app.ts │ ├── image.ts │ ├── monitor.ts │ ├── playlist.ts │ └── swww.ts ├── src ├── App.tsx ├── components │ ├── AddFoldersIcon.tsx │ ├── AddImagesCard.tsx │ ├── AdvancedFiltersModal.tsx │ ├── Drawer.tsx │ ├── Filters.tsx │ ├── Gallery.tsx │ ├── ImageCard.tsx │ ├── IntroScreen.tsx │ ├── LoadPlaylistModal.tsx │ ├── MiniPlaylistCard.tsx │ ├── Modals.tsx │ ├── Monitor.tsx │ ├── NavBar.tsx │ ├── PaginatedGallery.tsx │ ├── PlaylistConfigurationModal.tsx │ ├── PlaylistTrack.tsx │ ├── SavePlaylistModal.tsx │ ├── Skeleton.tsx │ ├── addImagesIcon.tsx │ └── monitorsModal.tsx ├── custom.css ├── hooks │ ├── useDebounce.tsx │ ├── useDebounceCallback.tsx │ ├── useFilteredImages.tsx │ ├── useImagePagination.tsx │ ├── useLoadAppConfig.tsx │ ├── useLoadImages.tsx │ ├── useOnDeleteImage.tsx │ ├── useOpenImages.tsx │ ├── useSetLastActivePlaylist.tsx │ ├── useThrottle.tsx │ ├── useTimeout.tsx │ └── useWindowSize.tsx ├── index.css ├── main.tsx ├── routes │ ├── AppConfiguration.tsx │ ├── Home.tsx │ └── SwwwConfig.tsx ├── stores │ ├── appConfig.tsx │ ├── images.tsx │ ├── monitors.tsx │ ├── playlist.tsx │ └── swwwConfig.tsx ├── types │ └── rendererTypes.ts ├── utils │ └── utilities.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.tsbuildinfo ├── types └── types.ts ├── utils ├── imageOperations.ts ├── monitorUtils.ts └── notifications.ts ├── vite.config.ts └── waypaper-engine.desktop /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "npm" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | - package-ecosystem: "npm" 12 | directory: "/daemon" 13 | schedule: 14 | interval: "weekly" 15 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Test build 5 | 6 | on: ["pull_request"] 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x, 22.x,23.x] 16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | - run: npm ci 26 | - run: npm run build --if-present 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # Editor directories and files 11 | .vscode/* 12 | !.vscode/extensions.json 13 | .idea 14 | .DS_Store 15 | *.suo 16 | *.ntvs* 17 | *.njsproj 18 | *.sln 19 | *.sw? 20 | 21 | # project specific ignores 22 | *.js 23 | *.js.map 24 | node_modules 25 | dist 26 | dist-ssr 27 | dist-electron 28 | release 29 | *.local 30 | waypaper.sqlite3 31 | # exclude from gitignore 32 | 33 | !postcss.config.js 34 | !tailwind.config.js 35 | 36 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore build output directories 2 | dist/ 3 | 4 | # Ignore node modules 5 | node_modules/ 6 | 7 | # Ignore specific configuration files 8 | *.config.js 9 | *.yml 10 | 11 | # Ignore environment variables files 12 | .env 13 | .env.* 14 | 15 | # Ignore lock files 16 | package-lock.json 17 | 18 | # Ignore logs 19 | *.log 20 | 21 | # Ignore .md files 22 | 23 | *.md 24 | *.MD 25 | 26 | # Ignore compiled files 27 | *.min.js 28 | *.min.css 29 | *.js 30 | 31 | *.json 32 | 33 | # Ignore specific file types 34 | *.png 35 | *.jpg 36 | *.jpeg 37 | *.gif 38 | *.svg 39 | 40 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": false, 4 | "trailingComma": "none", 5 | "tabWidth": 4, 6 | "bracketSpacing": true, 7 | "arrowParens": "avoid", 8 | "useTabs": false, 9 | "plugins": ["prettier-plugin-tailwindcss"] 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | banner 3 | 4 | ![GitHub last commit (branch)](https://img.shields.io/github/last-commit/0bCdian/Waypaper-Engine/main?style=for-the-badge&logo=git&color=%2389B482) 5 | ![AUR last modified](https://img.shields.io/aur/last-modified/waypaper-engine?style=for-the-badge&logo=arch-linux&color=%23438287) 6 | ![GitHub Repo stars](https://img.shields.io/github/stars/0bCdian/Waypaper-Engine?style=for-the-badge&logo=github&color=%232AAEA3) 7 | ![Badge Language](https://img.shields.io/github/languages/top/0bCdian/Waypaper-Engine?style=for-the-badge&logo=typescript) 8 | ![Badge License](https://img.shields.io/github/license/0bCdian/Waypaper-Engine?style=for-the-badge&logo=gnu) 9 | 10 | ### _A wallpaper setter gui, developed with ricing in mind!_ 🍚 11 | 12 | **[
Why
](#why)** 13 | **[
 How to install 
](#install)** 14 | **[
 Usage 
](#usage)** 15 | **[
 TODO 
](#todo)** 16 | **[
 Gallery 
](#gallery)** 17 | **[
 Special Thanks 
](#special-thanks)** 18 | 19 |
20 | 21 | # Features 22 | 23 | - Multi monitor support. 24 | - Four different types of playlists (Time of day, daily,interval based or static). 25 | - Easy configuration of all [swww](https://github.com/Horus645/swww) options. 26 | - Tray controls. 27 | - CLI Support. 28 | - All of [swww](https://github.com/Horus645/swww) features such as wallpaper change animations and wallpaper persistance through reboots. 29 | - Filter images by format, resolution,name,etc. 30 | - Run scripts on image set. 31 |
32 |
33 | 34 | --- 35 | 36 | ![image](https://github.com/0bCdian/Waypaper-Engine/assets/101421807/40318ad6-aa5a-42c2-98c8-63d988069407) 37 | 38 | https://github.com/0bCdian/Waypaper-Engine/assets/101421807/4d49225a-cbdc-42a0-af67-aac823c47f98 39 | 40 | --- 41 | 42 | # Why 43 | 44 | I started this project for two main reasons, one as a learning oportunity, and two because the available options for a tool like this didn't suit my needs fully. I really like [swww](https://github.com/Horus645/swww) but it lacks a lot of the features that I missed from wallpaper engine in windows, so this is my attempt to bridge that gap a little. 45 | 46 | # Install 47 | 48 | Simply install from the aur like so: 49 | 50 | ```bash 51 | yay -S waypaper-engine 52 | ``` 53 | 54 | or 55 | 56 | ```bash 57 | yay -S waypaper-engine-git 58 | ``` 59 | 60 | Both the normal and -git version conflict with each other, so make sure to delete the other with `yay -Rns package_name package_name-debug` before installing either. 61 | 62 | ## Manual installation 63 | 64 | Be advised you will need to run some of the commands with sudo privileges as you will be copying files to protected paths. 65 | 66 | 1. Clone this repo `git clone git@github.com:0bCdian/Waypaper-Engine.git` or download and extract the zip file 67 | 2. cd into Waypaper-Engine 68 | 3. run `npm run build` 69 | 4. cd into release `cd release` 70 | 5. Optional: if you want to change the tray icon, change the 512x512.png image inside linux-unpacked/resources/icons 71 | 6. Optional: copy the 512x512.png file to the icons system directory `cp linux-unpacked/resources/icons/512x512.png /usr/share/icons/hicolor/512x512/apps/waypaper-engine.png` 72 | 7. copy the whole directory to it's final destination `sudo cp ./linux-unpacked -rt /opt/waypaper-engine` 73 | 8. go back to root dir `cd ..` 74 | 9. cd into cli `cd cli` 75 | 10. copy waypaper-engine file to your $PATH or /usr/bin `sudo cp ./waypaper-engine /usr/bin` or `cp ./waypaper/engine $HOME/.local/bin` be advised, if you do not copy the cli file to /usr/bin, the path you're copying it into must be in your $PATH, read [this](https://askubuntu.com/questions/551990/what-does-path-mean) for more info 76 | 11. go back to root `cd ..` 77 | 12. copy the .desktop file to /usr/share/applications `sudo cp ./waypaper-engine.desktop /usr/share/applications/` 78 | 79 | and you're done! 80 | 81 | # Usage 82 | 83 | Simply start the app and add wallpapers to the gallery, from there you can double click to set the wallpapers or right click for more options, to create playlists simply click on the checkboxes that appear when hover over the images, and configure it, and then save it to auto start it. 84 | 85 | # Examples 86 | 87 | ### Autostart on hyprland just the daemon 88 | 89 | Add to your hyprland.conf the following lines: 90 | 91 | ```bash 92 | exec-once=waypaper-engine daemon 93 | ``` 94 | 95 | ### Add scripts to run on each image set 96 | 97 | > [!WARNING] 98 | > Make sure the script in question has execution permissions by using `chmod +x scriptname.sh` 99 | > Put you bash scripts in this path: 100 | 101 | ```bash 102 | $HOME/.waypaper_engine/scripts 103 | ``` 104 | 105 | The scripts are always passsed as an argument the path of the image being set, so you can do stuff like this: 106 | 107 | ![carbon](https://github.com/0bCdian/Waypaper-Engine/assets/101421807/c594babf-198a-47a0-8dce-5fd8d64b862c) 108 | 109 | https://github.com/0bCdian/Waypaper-Engine/assets/101421807/f454a904-7fa7-4ce9-86e9-f8fbc86e8c2b 110 | 111 | # TODO 112 | 113 | - [ ] Add testing. 114 | - [ ] Have a ci/cd pipeline. 115 | - [x] Implement a logger for errors. 116 | - [x] Publish in the aur. 117 | - [x] Find a good icon/logo for the app (Thank you [Cristian Avendaño](https://github.com/c-avendano)!). 118 | - [ ] Add flatpak support. 119 | - [x] Add scripts feature. 120 | - [x] Add playlists per monitor. 121 | 122 | _If you encounter any problems or would like to make a suggestion, please feel free to open an issue_. 123 | 124 | # Gallery 125 | 126 | ![image](https://github.com/0bCdian/Waypaper-Engine/assets/101421807/d78b9373-daf8-401a-8e85-cd1e286b31ce) 127 | ![image](https://github.com/0bCdian/Waypaper-Engine/assets/101421807/aceae307-7a2a-430e-a357-c710bb660eb7) 128 | ![image](https://github.com/0bCdian/Waypaper-Engine/assets/101421807/c78b7fc9-48a6-4ffa-b07f-a58f73ca91b6) 129 | ![image](https://github.com/0bCdian/Waypaper-Engine/assets/101421807/cb6afa04-b577-46a6-ba53-70fdf304c1b6) 130 | ![image](https://github.com/0bCdian/Waypaper-Engine/assets/101421807/51e2e981-8916-475e-92cd-b33e4a9bbaa5) 131 | ![image](https://github.com/0bCdian/Waypaper-Engine/assets/101421807/495d6702-7ce9-4d5b-9870-5cf0d2aa56bb) 132 | ![image](https://github.com/0bCdian/Waypaper-Engine/assets/101421807/ba5993ff-ea36-4594-bc77-671c082f09c2) 133 | 134 | # Special Thanks 135 | 136 | **[LGFae](https://github.com/LGFae)** - _for the amazing little tool that swww is !_ ❤️ 137 | 138 | **[Simon Ser](https://git.sr.ht/~emersion/)** - _for wlr-randr, without it making this work across different wayland wm's would've been a nightmare_ 🥲 139 | 140 | **[Cristian Avendaño](https://github.com/c-avendano)** - _for creating the amazing logo!_ 💪 141 | -------------------------------------------------------------------------------- /build/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/build/icons/128x128.png -------------------------------------------------------------------------------- /build/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/build/icons/16x16.png -------------------------------------------------------------------------------- /build/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/build/icons/256x256.png -------------------------------------------------------------------------------- /build/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/build/icons/32x32.png -------------------------------------------------------------------------------- /build/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/build/icons/512x512.png -------------------------------------------------------------------------------- /build/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/build/icons/64x64.png -------------------------------------------------------------------------------- /cli/src/bashly.yml: -------------------------------------------------------------------------------- 1 | name: waypaper-engine 2 | help: A wallpaper setting tool with batteries included! 3 | version: 2.0.4 4 | 5 | commands: 6 | - name: run 7 | alias: r 8 | help: Run the waypaper engine application 9 | 10 | flags: 11 | - long: --wayland 12 | help: force app to run under wayland instead of xwayland. 13 | required: false 14 | 15 | - long: --format 16 | short: -f 17 | help: Starts swww in xrgb mode. 18 | required: false 19 | 20 | - long: --logs 21 | short: -l 22 | help: Enable file logging. 23 | required: false 24 | 25 | examples: 26 | - waypaper-engine 27 | - waypaper-engine run 28 | - waypaper-engine run --wayland 29 | - waypaper-engine run --logs 30 | 31 | - name: daemon 32 | alias: d 33 | help: Run just the waypaper engine daemon 34 | 35 | flags: 36 | - long: --format 37 | short: -f 38 | help: Starts swww in xrgb mode. 39 | required: false 40 | 41 | - long: --logs 42 | short: -l 43 | help: Enable file logging. 44 | required: false 45 | 46 | examples: 47 | - waypaper-engine daemon 48 | - waypaper-engine daemon --format 49 | - waypaper-engine daemon --logs 50 | 51 | - name: next-image 52 | alias: ni 53 | help: Send a request to the daemon to set the next image in the specified playlist 54 | args: 55 | - name: playlist 56 | help: Send next image command to a specific playlist, if left empty will send the command to all active playlists. 57 | 58 | - name: active_monitor 59 | help: If not specified, will send the next command to all active playlist with the name provided. 60 | examples: 61 | - waypaper-engine next-image 62 | - waypaper-engine next-image playlist_name 63 | - waypaper-engine next-image playlist_name active_monitor 64 | 65 | - name: previous-image 66 | alias: pi 67 | help: Send a request to the daemon to set the previous image in the specified playlist 68 | args: 69 | - name: playlist 70 | help: Send previous image command to a specific playlist, if left empty will send the command to all active playlists. 71 | 72 | - name: active_monitor 73 | help: If not specified, will send the previous command to all active playlist with the name provided. 74 | examples: 75 | - waypaper-engine previous-image 76 | - waypaper-engine previous-image playlist_name 77 | - waypaper-engine previous-image playlist_name active_monitor 78 | 79 | - name: kill-daemon 80 | alias: kd 81 | help: Kill the waypaper daemon process 82 | examples: 83 | - waypaper-engine kill-daemon 84 | - waypaper-engine kd 85 | 86 | - name: stop-daemon 87 | alias: sd 88 | help: Send stop command to daemon process 89 | examples: 90 | - waypaper-engine stop-daemon 91 | - waypaper-engine sd 92 | 93 | - name: pause-playlist 94 | alias: pp 95 | help: Send a request to the daemon to pause the specified playlist 96 | args: 97 | - name: playlist 98 | help: Send pause command to a specific playlist, if left empty will send the command to all active playlists. 99 | 100 | - name: active_monitor 101 | help: If not specified, will send the pause command to all active playlist with the name provided. 102 | examples: 103 | - waypaper-engine pause-playlist 104 | - waypaper-engine pause-playlist playlist_name 105 | - waypaper-engine pause-playlist_name playlist active_monitor 106 | 107 | - name: resume-playlist 108 | alias: rp 109 | help: Send a request to the daemon to resume the specified playlist 110 | args: 111 | - name: playlist 112 | help: Send resume command to a specific playlist, if left empty will send the command to all active playlists. 113 | 114 | - name: active_monitor 115 | help: If not specified, will send the resume command to all active playlist with the name provided. 116 | examples: 117 | - waypaper-engine resume-playlist 118 | - waypaper-engine resume-playlist playlist_name 119 | - waypaper-engine resume-playlist playlist_name active_monitor 120 | 121 | - name: start-playlist 122 | help: Start a playlist from the interactive selection 123 | 124 | examples: 125 | - waypaper-engine start-playlist 126 | 127 | - name: stop-playlist 128 | alias: sp 129 | help: Send a request to the daemon to stop the current playlist 130 | args: 131 | - name: playlist 132 | help: Send stop command to a specific playlist, if left empty will send the command to all active playlists. 133 | 134 | - name: active_monitor 135 | help: If not specified, will send the stop command to all active playlist with the name provided. 136 | 137 | examples: 138 | - waypaper-engine stop-playlist 139 | - waypaper-engine stop-playlist playlist_name 140 | - waypaper-engine stop-playlist playlist_name active_monitor 141 | 142 | - name: random 143 | alias: ri 144 | help: Set a random image from the gallery 145 | 146 | examples: 147 | - waypaper-engine random 148 | - waypaper-engine ri 149 | 150 | - name: info-playlist 151 | alias: ip 152 | help: Get active playlists diagnostics 153 | 154 | examples: 155 | - waypaper-engine info-playlist 156 | - waypaper-engine ip 157 | 158 | - name: info 159 | alias: i 160 | help: Get monitors info and it's currently displayed images. 161 | examples: 162 | - waypaper-engine info 163 | - waypaper-engine i 164 | 165 | - name: playlist 166 | help: Launch interactive playlist controller 167 | examples: 168 | - waypaper-engine playlist 169 | - waypaper-engine p 170 | 171 | - name: history 172 | alias: h 173 | help: Launch interactive image history 174 | examples: 175 | - waypaper-engine history 176 | - waypaper-engine h 177 | -------------------------------------------------------------------------------- /cli/src/daemon_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_LOCATION="/opt/waypaper-engine/waypaper-engine-bin --daemon" 2 | 3 | FORMAT="${args[--format]}" 4 | LOG="${args[--logs]}" 5 | COMMAND="$WAYPAPER_LOCATION" 6 | 7 | if [[ $FORMAT -eq 1 ]]; then 8 | COMMAND="$COMMAND --format" 9 | fi 10 | if [[ $LOG -eq 1 ]]; then 11 | COMMAND="$COMMAND --logs" 12 | fi 13 | $COMMAND || "Something went wrong" 14 | -------------------------------------------------------------------------------- /cli/src/history_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH="/tmp/waypaper_engine_daemon.sock" 2 | 3 | execute_command() { 4 | local argument="$1" 5 | echo -n "$argument" | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq || { 6 | echo "Something went wrong" 7 | exit 1 8 | } 9 | } 10 | 11 | HISTORY=$(echo -n '{"action":"get-image-history"}' | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq || "Something went wrong") 12 | SELECTED_OBJECT=$(echo "$HISTORY" | jq ".[]" -c | fzf --border sharp --reverse --no-height --header "Select image to set" --preview "printf %s {} | jq" -e) 13 | COMMAND=$(echo "$SELECTED_OBJECT" | jq "{action:\"set-image\",image:.Images,activeMonitor:.imageHistory.monitor}" -rc) 14 | 15 | execute_command "$COMMAND" 16 | -------------------------------------------------------------------------------- /cli/src/info_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH="/tmp/waypaper_engine_daemon.sock" 2 | 3 | echo -n '{"action":"get-info"}' | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq || "Something went wrong" 4 | -------------------------------------------------------------------------------- /cli/src/info_playlist_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH="/tmp/waypaper_engine_daemon.sock" 2 | 3 | echo -n '{"action":"get-info-active-playlist"}' | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq || "Something went wrong" 4 | -------------------------------------------------------------------------------- /cli/src/kill_daemon_command.sh: -------------------------------------------------------------------------------- 1 | echo "# this file is located in 'src/kill_daemon_command.sh'" 2 | echo "# code for 'waypaper-engine kill-daemon' goes here" 3 | echo "# you can edit it freely and regenerate (it will not be overwritten)" 4 | inspect_args 5 | -------------------------------------------------------------------------------- /cli/src/next_image_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH="/tmp/waypaper_engine_daemon.sock" 2 | PLAYLIST="${args[playlist]}" 3 | ACTIVE_MONITOR="${args[active_monitor]}" 4 | COMMAND="next-image" 5 | COMMAND_ALL="next-image-all" 6 | execute_command() { 7 | local argument="$1" 8 | echo "$argument" | jq 9 | echo -n "$argument" | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq || { 10 | echo "Something went wrong" 11 | exit 1 12 | } 13 | } 14 | 15 | if [[ -z "$PLAYLIST" ]]; then 16 | execute_command "{\"action\": \"$COMMAND_ALL\"}" 17 | elif [[ -z "$ACTIVE_MONITOR" ]]; then 18 | execute_command "{\"action\": \"$COMMAND\", \"playlist\": {\"name\": \"$PLAYLIST\"}}" 19 | else 20 | execute_command "{\"action\": \"$COMMAND\", \"playlist\": {\"name\": \"$PLAYLIST\", \"activeMonitor\":{\"name\":\"$ACTIVE_MONITOR\"}}}" 21 | fi 22 | -------------------------------------------------------------------------------- /cli/src/pause_playlist_command.sh: -------------------------------------------------------------------------------- 1 | echo "# this file is located in 'src/pause_playlist_command.sh'" 2 | echo "# code for 'waypaper-engine pause-playlist' goes here" 3 | echo "# you can edit it freely and regenerate (it will not be overwritten)" 4 | inspect_args 5 | -------------------------------------------------------------------------------- /cli/src/playlist_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH="/tmp/waypaper_engine_daemon.sock" 2 | 3 | execute_command() { 4 | local argument="$1" 5 | echo "$argument" 6 | echo -n "$argument" | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq || { 7 | echo "Something went wrong" 8 | exit 1 9 | } 10 | } 11 | 12 | ACTIVE_PLAYLISTS=$(echo -n '{"action":"get-info-active-playlist"}' | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq) 13 | SELECTED_PLAYLIST=$(echo "$ACTIVE_PLAYLISTS" | jq ".[]" -c | fzf --border sharp --reverse --no-height --header "Active Playlists" --preview "printf %s {} | jq" -e) 14 | COMMANDS=("next-image" "previous-image" "stop-playlist" "resume-playlist" "pause-playlist") 15 | SELECTED_COMMAND=$(printf "%s\n" "${COMMANDS[@]}" | fzf --border sharp --reverse --no-height --header "Select command" --preview "printf %s {}" -e) 16 | COMMAND=$(echo "$SELECTED_PLAYLIST" | jq "{action:\"$SELECTED_COMMAND\",playlist:{name:.playlistName,activeMonitor:.playlistActiveMonitor}}" -rc) 17 | execute_command "$COMMAND" 18 | -------------------------------------------------------------------------------- /cli/src/previous_image_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH="/tmp/waypaper_engine_daemon.sock" 2 | PLAYLIST="${args[playlist]}" 3 | ACTIVE_MONITOR="${args[active_monitor]}" 4 | COMMAND="previous-image" 5 | COMMAND_ALL="previous-image-all" 6 | execute_command() { 7 | local argument="$1" 8 | echo "$argument" | jq 9 | echo -n "$argument" | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq || { 10 | echo "Something went wrong" 11 | exit 1 12 | } 13 | } 14 | 15 | if [[ -z "$PLAYLIST" ]]; then 16 | execute_command "{\"action\": \"$COMMAND_ALL\"}" 17 | elif [[ -z "$ACTIVE_MONITOR" ]]; then 18 | execute_command "{\"action\": \"$COMMAND\", \"playlist\": {\"name\": \"$PLAYLIST\"}}" 19 | else 20 | execute_command "{\"action\": \"$COMMAND\", \"playlist\": {\"name\": \"$PLAYLIST\", \"activeMonitor\":{\"name\":\"$ACTIVE_MONITOR\"}}}" 21 | fi 22 | -------------------------------------------------------------------------------- /cli/src/random_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH="/tmp/waypaper_engine_daemon.sock" 2 | 3 | echo -n '{"action":"random-image"}' | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH >/dev/null || "Something went wrong" 4 | -------------------------------------------------------------------------------- /cli/src/resume_playlist_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH="/tmp/waypaper_engine_daemon.sock" 2 | PLAYLIST="${args[playlist]}" 3 | ACTIVE_MONITOR="${args[active_monitor]}" 4 | COMMAND="resume-playlist" 5 | COMMAND_ALL="resume-playlist-all" 6 | execute_command() { 7 | local argument="$1" 8 | echo "$argument" | jq 9 | echo -n "$argument" | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq || { 10 | echo "Something went wrong" 11 | exit 1 12 | } 13 | } 14 | 15 | if [[ -z "$PLAYLIST" ]]; then 16 | execute_command "{\"action\": \"$COMMAND_ALL\"}" 17 | elif [[ -z "$ACTIVE_MONITOR" ]]; then 18 | execute_command "{\"action\": \"$COMMAND\", \"playlist\": {\"name\": \"$PLAYLIST\"}}" 19 | else 20 | execute_command "{\"action\": \"$COMMAND\", \"playlist\": {\"name\": \"$PLAYLIST\", \"activeMonitor\":{\"name\":\"$ACTIVE_MONITOR\"}}}" 21 | fi 22 | -------------------------------------------------------------------------------- /cli/src/run_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_LOCATION="/opt/waypaper-engine/waypaper-engine-bin" 2 | WAYPAPER_FLAGS="$HOME/.waypaper_engine/flags.conf" 3 | FORMAT="${args[--format]}" 4 | WAYLAND="${args[--wayland]}" 5 | LOG="${args[--logs]}" 6 | COMMAND="$WAYPAPER_LOCATION" 7 | 8 | if [[ $WAYLAND -eq 1 ]]; then 9 | COMMAND="$COMMAND --ozone-platform-hint=wayland" 10 | fi 11 | if [[ $FORMAT -eq 1 ]]; then 12 | COMMAND="$COMMAND --format" 13 | fi 14 | if [[ $LOG -eq 1 ]]; then 15 | COMMAND="$COMMAND --logs" 16 | fi 17 | 18 | $COMMAND || "Something went wrong" 19 | -------------------------------------------------------------------------------- /cli/src/start_playlist_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH="/tmp/waypaper_engine_daemon.sock" 2 | 3 | execute_command() { 4 | local argument="$1" 5 | echo -n "$argument" | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq || { 6 | echo "Something went wrong" 7 | exit 1 8 | } 9 | } 10 | 11 | PLAYLISTS=$(echo -n '{"action":"get-info-playlist"}' | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq) 12 | SELECTED_PLAYLIST=$(echo "$PLAYLISTS" | jq ".[]" -c | fzf --border sharp --reverse --no-height --header "Select a playlists" --preview "printf %s {} | jq" -e) 13 | MONITORS=$(echo -n '{"action":"get-info"}' | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq) 14 | SELECTED_MONITORS=$(echo "$MONITORS" | jq ".[]" -c | fzf --border sharp --reverse --no-height --multi --header "Select a monitor" --preview "printf %s {} | jq" -e | jq -s "sort_by(.position.x)") 15 | NAMES=$(echo "$SELECTED_MONITORS" | jq -r 'map(.name) | join(",")') 16 | MODES=("extend" "clone") 17 | if [ "$(echo "$SELECTED_MONITORS" | jq length)" -gt 1 ]; then 18 | SELECTED_MODE=$(printf "%s\n" "${MODES[@]}" | fzf --border sharp --reverse --no-height --header "Select monitor mode" --preview "printf %s {}" -e) 19 | if [ "$SELECTED_MODE" == "extend" ]; then 20 | # If it is, set the boolean variable to true 21 | EXTEND=true 22 | else 23 | # Otherwise, set it to false 24 | EXTEND=false 25 | fi 26 | else 27 | EXTEND=false 28 | fi 29 | COMMAND=$(echo "$SELECTED_PLAYLIST" | jq -rc "{action:\"start-playlist\",playlist:{name:.playlist.name,activeMonitor:{name:\"$NAMES\",monitors:$SELECTED_MONITORS,extendAcrossMonitors:$EXTEND}}}") 30 | 31 | execute_command "$COMMAND" 32 | -------------------------------------------------------------------------------- /cli/src/stop_daemon_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH="/tmp/waypaper_engine_daemon.sock" 2 | 3 | echo -n '{"action":"stop-daemon"}' | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH >/dev/null || "Something went wrong" 4 | -------------------------------------------------------------------------------- /cli/src/stop_playlist_command.sh: -------------------------------------------------------------------------------- 1 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH="/tmp/waypaper_engine_daemon.sock" 2 | PLAYLIST="${args[playlist]}" 3 | ACTIVE_MONITOR="${args[active_monitor]}" 4 | COMMAND="stop-playlist" 5 | COMMAND_ALL="stop-playlist-all" 6 | COMMAND_NAME="stop-playlist-by-name" 7 | execute_command() { 8 | local argument="$1" 9 | echo "$argument" | jq 10 | echo -n "$argument" | socat - UNIX-CONNECT:$WAYPAPER_ENGINE_DAEMON_SOCKET_PATH | jq || { 11 | echo "Something went wrong" 12 | exit 1 13 | } 14 | } 15 | 16 | if [[ -z "$PLAYLIST" ]]; then 17 | execute_command "{\"action\": \"$COMMAND_ALL\"}" 18 | elif [[ -z "$ACTIVE_MONITOR" ]]; then 19 | execute_command "{\"action\": \"$COMMAND_NAME\", \"playlist\": {\"name\": \"$PLAYLIST\"}}" 20 | else 21 | execute_command "{\"action\": \"$COMMAND\", \"playlist\": {\"name\": \"$PLAYLIST\", \"activeMonitor\":{\"name\":\"$ACTIVE_MONITOR\"}}}" 22 | fi 23 | -------------------------------------------------------------------------------- /daemon/daemon.ts: -------------------------------------------------------------------------------- 1 | import { DaemonManager } from "./daemonManager"; 2 | import { acquireLock, releaseLock } from "../globals/startDaemons"; 3 | import { notify } from "../utils/notifications"; 4 | import { logger } from "../globals/setup"; 5 | import { configuration } from "../globals/config"; 6 | 7 | if (!acquireLock()) { 8 | logger.warn("Another instance is already running"); 9 | process.exit(1); 10 | } 11 | process.title = configuration.DAEMON_PID; 12 | try { 13 | const daemonManager = new DaemonManager(); 14 | process.on("SIGTERM", function () { 15 | notify("Exiting daemon..."); 16 | daemonManager.cleanUp(); 17 | releaseLock(); 18 | process.exit(0); 19 | }); 20 | process.on("SIGHUP", () => { 21 | daemonManager.cleanUp(); 22 | releaseLock(); 23 | process.exit(0); 24 | }); 25 | process.on("SIGINT", () => { 26 | notify("Exiting daemon..."); 27 | daemonManager.cleanUp(); 28 | releaseLock(); 29 | process.exit(0); 30 | }); 31 | process.on("uncaughtException", _ => { 32 | notify( 33 | `Daemon crashed, run with --logs to generate flags in $HOME/.waypaper_engine/` 34 | ); 35 | daemonManager.cleanUp(); 36 | releaseLock(); 37 | process.exit(1); 38 | }); 39 | process.on("unhandledRejection", _ => { 40 | notify( 41 | `Daemon crashed, run with --logs to generate flags in $HOME/.waypaper_engine/` 42 | ); 43 | daemonManager.cleanUp(); 44 | releaseLock(); 45 | process.exit(1); 46 | }); 47 | process.on("exit", releaseLock); 48 | } catch (error) { 49 | logger.error(error); 50 | releaseLock(); 51 | process.exit(1); 52 | } 53 | -------------------------------------------------------------------------------- /daemon/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "daemon", 3 | "version": "2.0.4", 4 | "description": "", 5 | "main": "daemon.ts", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "better-sqlite3": "11.8.1", 14 | "chokidar": "4.0.3", 15 | "drizzle-orm": "0.39.1", 16 | "pino": "9.6.0", 17 | "sharp": "0.33.5" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /database/database.ts: -------------------------------------------------------------------------------- 1 | import { 2 | drizzle, 3 | type BetterSQLite3Database 4 | } from "drizzle-orm/better-sqlite3"; 5 | import { migrate } from "drizzle-orm/better-sqlite3/migrator"; 6 | import { 7 | nativeBindingPath, 8 | migrationsPathDaemon, 9 | migrationsPath, 10 | isDaemon, 11 | mainDirectory, 12 | logger 13 | } from "../globals/setup"; 14 | import Database = require("better-sqlite3"); 15 | import { join } from "node:path"; 16 | const dbPath = join(mainDirectory, "images_database.sqlite3"); 17 | const migrations = isDaemon ? migrationsPathDaemon : migrationsPath; 18 | export function createConnector() { 19 | try { 20 | const sqlite = Database( 21 | dbPath, 22 | isDaemon ? undefined : { nativeBinding: nativeBindingPath } 23 | ); 24 | const drizzleDB: BetterSQLite3Database = drizzle(sqlite); 25 | return drizzleDB; 26 | } catch (error) { 27 | logger.error(error); 28 | throw new Error( 29 | `Could not create better-sqlite3 connector ${isDaemon ? "daemon" : "electron"} ${__dirname}` 30 | ); 31 | } 32 | } 33 | 34 | export function migrateDB() { 35 | try { 36 | const drizzleDB = createConnector(); 37 | migrate(drizzleDB, { migrationsFolder: migrations }); 38 | } catch (error) { 39 | logger.error(error); 40 | } 41 | } 42 | 43 | migrateDB(); 44 | -------------------------------------------------------------------------------- /database/migrations/0000_harsh_siren.sql: -------------------------------------------------------------------------------- 1 | -- Current sql file was generated after introspecting the database 2 | -- If you want to run this migration please uncomment this code before executing migrations 3 | 4 | CREATE TABLE IF NOT EXISTS `swwwConfig` ( 5 | `resizeType` text NOT NULL, 6 | `fillColor` text NOT NULL, 7 | `filterType` text NOT NULL, 8 | `transitionType` text NOT NULL, 9 | `transitionStep` integer NOT NULL, 10 | `transitionDuration` integer NOT NULL, 11 | `transitionFPS` integer NOT NULL, 12 | `transitionAngle` integer NOT NULL, 13 | `transitionPositionType` text NOT NULL, 14 | `transitionPosition` text NOT NULL, 15 | `transitionPositionIntX` integer NOT NULL, 16 | `transitionPositionIntY` integer NOT NULL, 17 | `transitionPositionFloatX` real NOT NULL, 18 | `transitionPositionFloatY` real NOT NULL, 19 | `invertY` integer NOT NULL, 20 | `transitionBezier` text NOT NULL, 21 | `transitionWaveX` integer NOT NULL, 22 | `transitionWaveY` integer NOT NULL 23 | ); 24 | --> statement-breakpoint 25 | CREATE TABLE IF NOT EXISTS `appConfig` ( 26 | `killDaemon` integer NOT NULL, 27 | `playlistStartOnFirstImage` integer NOT NULL, 28 | `notifications` integer NOT NULL, 29 | `swwwAnimations` integer NOT NULL, 30 | `introAnimation` integer NOT NULL, 31 | `startMinimized` integer NOT NULL, 32 | `minimizeInsteadOfClose` integer NOT NULL 33 | ); 34 | --> statement-breakpoint 35 | CREATE TABLE IF NOT EXISTS `Images` ( 36 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 37 | `name` text NOT NULL, 38 | `isChecked` integer DEFAULT 0 NOT NULL, 39 | `width` integer NOT NULL, 40 | `height` integer NOT NULL, 41 | `format` text NOT NULL 42 | ); 43 | --> statement-breakpoint 44 | CREATE TABLE IF NOT EXISTS `Playlists` ( 45 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 46 | `name` text NOT NULL, 47 | `type` text NOT NULL, 48 | `interval` integer DEFAULT (null), 49 | `showAnimations` integer DEFAULT 1 NOT NULL, 50 | `order` text DEFAULT (null), 51 | `currentImageIndex` integer DEFAULT 0 NOT NULL 52 | ); 53 | --> statement-breakpoint 54 | CREATE TABLE IF NOT EXISTS `imagesInPlaylist` ( 55 | `imageID` integer NOT NULL, 56 | `playlistID` integer NOT NULL, 57 | `indexInPlaylist` integer NOT NULL, 58 | `time` integer DEFAULT (null), 59 | FOREIGN KEY (`playlistID`) REFERENCES `Playlists`(`id`) ON UPDATE cascade ON DELETE cascade, 60 | FOREIGN KEY (`imageID`) REFERENCES `Images`(`id`) ON UPDATE cascade ON DELETE cascade 61 | ); 62 | --> statement-breakpoint 63 | CREATE TABLE IF NOT EXISTS `activePlaylist` ( 64 | `playlistID` integer PRIMARY KEY 65 | ); 66 | 67 | -------------------------------------------------------------------------------- /database/migrations/0001_flat_champions.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `swwwConfig` RENAME TO `swwwConfigOld`; 2 | --> statement-breakpoint 3 | ALTER TABLE `appConfig` RENAME TO `appConfigOld`; 4 | --> statement-breakpoint 5 | ALTER TABLE `imagesInPlaylist` RENAME TO `oldImagesInPlaylist`;--> statement-breakpoint 6 | CREATE TABLE "imagesInPlaylist" ( 7 | "imageID" INTEGER NOT NULL, 8 | "playlistID" INTEGER NOT NULL, 9 | "indexInPlaylist" INTEGER NOT NULL, 10 | "time" INTEGER DEFAULT null, 11 | FOREIGN KEY("imageID") REFERENCES "Images"("id") ON UPDATE CASCADE ON DELETE CASCADE, 12 | FOREIGN KEY("playlistID") REFERENCES "Playlists"("id") ON UPDATE CASCADE ON DELETE CASCADE 13 | ); 14 | --> statement-breakpoint 15 | CREATE TABLE `swwwConfig` ( 16 | `config` text DEFAULT [object Object] NOT NULL 17 | ); 18 | --> statement-breakpoint 19 | CREATE TABLE `appConfig` ( 20 | `config` text DEFAULT [object Object] NOT NULL 21 | ); 22 | --> statement-breakpoint 23 | CREATE TABLE `imageHistory` ( 24 | `imageID` integer NOT NULL, 25 | `monitor` text NOT NULL, 26 | `time` text DEFAULT (CURRENT_TIME), 27 | FOREIGN KEY (`imageID`) REFERENCES `Images`(`id`) ON UPDATE no action ON DELETE cascade 28 | ); 29 | --> statement-breakpoint 30 | CREATE TABLE `activePlaylists` ( 31 | `playlistID` integer NOT NULL, 32 | `activeMonitor` text NOT NULL, 33 | `activeMonitorName` text NOT NULL UNIQUE, 34 | FOREIGN KEY (`playlistID`) REFERENCES `Playlists`(`id`) ON UPDATE no action ON DELETE cascade 35 | ); 36 | --> statement-breakpoint 37 | CREATE TABLE `selectedMonitor`( 38 | `monitor` text DEFAULT [object Object] NOT NULL 39 | ); 40 | --> statement-breakpoint 41 | INSERT INTO appConfig (config) 42 | SELECT json_object( 43 | 'killDaemon', CASE WHEN killDaemon = 1 THEN json('true') ELSE json('false') END, 44 | 'notifications', CASE WHEN notifications = 1 THEN json('true') ELSE json('false') END, 45 | 'introAnimation', CASE WHEN introAnimation = 1 THEN json('true') ELSE json('false') END, 46 | 'startMinimized', CASE WHEN startMinimized = 1 THEN json('true') ELSE json('false') END, 47 | 'minimizeInsteadOfClose', CASE WHEN minimizeInsteadOfClose = 1 THEN json('true') ELSE json('false') END, 48 | 'randomImageMonitor','clone', 49 | 'imagesPerPage',20 50 | ) 51 | FROM appConfigOld; 52 | --> statement-breakpoint 53 | DROP TABLE `appConfigOld`; 54 | --> statement-breakpoint 55 | INSERT INTO swwwConfig (config) 56 | SELECT json_object( 57 | 'resizeType', resizeType, 58 | 'fillColor', fillColor, 59 | 'filterType', filterType, 60 | 'transitionType', transitionType, 61 | 'transitionStep', transitionStep, 62 | 'transitionDuration', transitionDuration, 63 | 'transitionFPS', transitionFPS, 64 | 'transitionAngle', transitionAngle, 65 | 'transitionPositionType', transitionPositionType, 66 | 'transitionPosition', transitionPosition, 67 | 'transitionPositionIntX', transitionPositionIntX, 68 | 'transitionPositionIntY', transitionPositionIntY, 69 | 'transitionPositionFloatX', transitionPositionFloatX, 70 | 'transitionPositionFloatY', transitionPositionFloatY, 71 | 'invertY', CASE WHEN invertY = 1 THEN json('true') ELSE json('false') END, 72 | 'transitionBezier', transitionBezier, 73 | 'transitionWaveX', transitionWaveX, 74 | 'transitionWaveY', transitionWaveY 75 | ) 76 | FROM swwwConfigOld;--> statement-breakpoint 77 | DROP TABLE swwwConfigOld;--> statement-breakpoint 78 | INSERT INTO imagesInPlaylist SELECT * FROM oldImagesInPlaylist;--> statement-breakpoint 79 | DROP TABLE activePlaylist;--> statement-breakpoint 80 | DROP TABLE oldImagesInPlaylist;--> statement-breakpoint 81 | ALTER TABLE Images ADD `isSelected` integer DEFAULT false NOT NULL;--> statement-breakpoint 82 | ALTER TABLE Playlists ADD `alwaysStartOnFirstImage` integer DEFAULT false NOT NULL;--> statement-breakpoint 83 | CREATE UNIQUE INDEX `Images_name_unique` ON `Images` (`name`);--> statement-breakpoint 84 | CREATE UNIQUE INDEX `Playlists_name_unique` ON `Playlists` (`name`);--> statement-breakpoint 85 | CREATE TRIGGER keep_recent_images 86 | AFTER INSERT ON imageHistory 87 | BEGIN 88 | DELETE FROM imageHistory 89 | WHERE rowid IN ( 90 | SELECT rowid 91 | FROM imageHistory 92 | ORDER BY rowid DESC 93 | LIMIT -1 OFFSET 10 94 | ); 95 | END; 96 | -------------------------------------------------------------------------------- /database/migrations/0002_gray_kylun.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS `imagesInPlaylist_time_unique`;--> statement-breakpoint 2 | ALTER TABLE `imageHistory` RENAME TO `imageHistoryOld`;--> statement-breakpoint 3 | CREATE TABLE `imageHistory` ( 4 | `imageID` integer NOT NULL, 5 | `monitor` text NOT NULL, 6 | `time` text DEFAULT (strftime('%s','now')), 7 | FOREIGN KEY (`imageID`) REFERENCES `Images`(`id`) ON UPDATE no action ON DELETE cascade 8 | );--> statement-breakpoint 9 | INSERT INTO `imageHistory` (`imageID`, `monitor`, `time`) 10 | SELECT `imageID`, `monitor`, `time` FROM `imageHistoryOld`;--> statement-breakpoint 11 | DROP TABLE `imageHistoryOld`;--> statement-breakpoint 12 | CREATE UNIQUE INDEX `activePlaylists_activeMonitorName_unique` ON `activePlaylists` (`activeMonitorName`); 13 | -------------------------------------------------------------------------------- /database/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1709639551269, 9 | "tag": "0000_harsh_siren", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "5", 15 | "when": 1709818312677, 16 | "tag": "0001_flat_champions", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "5", 22 | "when": 1720316579973, 23 | "tag": "0002_gray_kylun", 24 | "breakpoints": true 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /database/schema.ts: -------------------------------------------------------------------------------- 1 | import { text, integer, sqliteTable } from "drizzle-orm/sqlite-core"; 2 | import { type swwwConfig as swwwConfigType } from "../shared/types/swww"; 3 | import { 4 | type PLAYLIST_ORDER_TYPES, 5 | type PLAYLIST_TYPES_TYPE 6 | } from "../shared/types/playlist"; 7 | import { type appConfigType } from "../shared/types/app"; 8 | import { type Formats } from "../shared/types/image"; 9 | import { type ActiveMonitor } from "../shared/types/monitor"; 10 | import { sql } from "drizzle-orm"; 11 | export const image = sqliteTable("Images", { 12 | id: integer("id").notNull().primaryKey({ autoIncrement: true }), 13 | name: text("name").notNull().unique(), 14 | isChecked: integer("isChecked", { mode: "boolean" }) 15 | .notNull() 16 | .default(false), 17 | isSelected: integer("isSelected", { mode: "boolean" }) 18 | .notNull() 19 | .default(false), 20 | width: integer("width").notNull(), 21 | height: integer("height").notNull(), 22 | format: text("format").notNull().$type() 23 | }); 24 | 25 | export const playlist = sqliteTable("Playlists", { 26 | id: integer("id").notNull().primaryKey(), 27 | name: text("name").notNull().unique(), 28 | type: text("type").notNull().$type(), 29 | interval: integer("interval"), 30 | showAnimations: integer("showAnimations", { mode: "boolean" }) 31 | .notNull() 32 | .default(true), 33 | alwaysStartOnFirstImage: integer("alwaysStartOnFirstImage", { 34 | mode: "boolean" 35 | }) 36 | .notNull() 37 | .default(false), 38 | order: text("order").$type(), 39 | currentImageIndex: integer("currentImageIndex").notNull().default(0) 40 | }); 41 | 42 | export const imageInPlaylist = sqliteTable("imagesInPlaylist", { 43 | imageID: integer("imageID") 44 | .notNull() 45 | .references(() => image.id, { 46 | onUpdate: "cascade", 47 | onDelete: "cascade" 48 | }), 49 | playlistID: integer("playlistID") 50 | .notNull() 51 | .references(() => playlist.id, { 52 | onUpdate: "cascade", 53 | onDelete: "cascade" 54 | }), 55 | indexInPlaylist: integer("indexInPlaylist").notNull(), 56 | time: integer("time") 57 | }); 58 | 59 | export const swwwConfig = sqliteTable("swwwConfig", { 60 | config: text("config", { mode: "json" }).notNull().$type() 61 | }); 62 | 63 | export const appConfig = sqliteTable("appConfig", { 64 | config: text("config", { mode: "json" }).notNull().$type() 65 | }); 66 | 67 | export const activePlaylist = sqliteTable("activePlaylists", { 68 | playlistID: integer("playlistID") 69 | .notNull() 70 | .references(() => playlist.id), 71 | activeMonitor: text("activeMonitor", { mode: "json" }) 72 | .notNull() 73 | .$type(), 74 | activeMonitorName: text("activeMonitorName").notNull().unique() 75 | }); 76 | 77 | export const imageHistory = sqliteTable("imageHistory", { 78 | imageID: integer("imageID") 79 | .notNull() 80 | .references(() => image.id, { onDelete: "cascade" }), 81 | monitor: text("monitor", { mode: "json" }).notNull().$type(), 82 | time: text("time").default(sql`strftime('%s', 'now')`) 83 | }); 84 | 85 | export const selectedMonitor = sqliteTable("selectedMonitor", { 86 | monitor: text("monitor", { mode: "json" }).notNull().$type() 87 | }); 88 | 89 | export type imageSelectType = typeof image.$inferSelect; 90 | export type imageInsertType = typeof image.$inferInsert; 91 | export type swwwConfigSelectType = typeof swwwConfig.$inferSelect; 92 | export type swwwConfigInsertType = typeof swwwConfig.$inferInsert; 93 | export type appConfigSelectType = typeof appConfig.$inferSelect; 94 | export type appConfigInsertType = typeof appConfig.$inferInsert; 95 | export type playlistSelectType = typeof playlist.$inferSelect; 96 | export type playlistInsertType = typeof playlist.$inferInsert; 97 | export type imageInPlaylistSelectType = typeof imageInPlaylist.$inferSelect; 98 | export type imageInPlaylistInsertType = typeof imageInPlaylist.$inferInsert; 99 | export type imageHistorySelectType = typeof imageHistory.$inferSelect; 100 | export type imageHistoryInsertType = typeof imageHistory.$inferInsert; 101 | export type activePlaylistSelectType = typeof activePlaylist.$inferSelect; 102 | export type activePlaylistInsertType = typeof activePlaylist.$inferInsert; 103 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | import { homedir } from "os"; 3 | import { join } from "path"; 4 | const dbLocation = join(homedir(), ".waypaper_engine/images_database.sqlite3"); 5 | export default { 6 | schema: "./database/schema.ts", 7 | out: "./database/migrations/", 8 | driver: "better-sqlite", 9 | dbCredentials: { 10 | url: dbLocation 11 | } 12 | } satisfies Config; 13 | -------------------------------------------------------------------------------- /electron-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", 3 | "appId": "waypaper.engine", 4 | "asar": false, 5 | "npmRebuild": true, 6 | "directories": { 7 | "output": "release" 8 | }, 9 | "files": ["dist-electron", "dist"], 10 | "extraFiles": [ 11 | { 12 | "from": "build/icons/", 13 | "to": "resources/icons/", 14 | "filter": ["**/*"] 15 | }, 16 | { 17 | "from": "daemon", 18 | "to": "resources/daemon" 19 | }, 20 | { 21 | "from": "node_modules/better-sqlite3/build/Release/better_sqlite3.node", 22 | "to": "resources/better_sqlite3.node" 23 | }, 24 | { 25 | "from": "database/migrations", 26 | "to": "resources/migrations" 27 | } 28 | ], 29 | "linux": { 30 | "artifactName": "${productName}.${ext}", 31 | "category": "Utility", 32 | "target": ["dir"], 33 | "executableName": "waypaper-engine-bin", 34 | "description": "A graphical frontend for setting wallpapers and playlists, using swww under the hood!", 35 | "desktop": { 36 | "Name": "Waypaper Engine", 37 | "Comment": "Waypaper Engine", 38 | "Terminal": "false", 39 | "Type": "Application", 40 | "Icon": "/build/icons/512x512.png" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /electron-builder_AppImage.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", 3 | "appId": "waypaper.engine", 4 | "asar": false, 5 | "npmRebuild": true, 6 | "directories": { 7 | "output": "release" 8 | }, 9 | "files": ["dist-electron", "dist"], 10 | "extraFiles": [ 11 | { 12 | "from": "build/icons/", 13 | "to": "resources/icons/", 14 | "filter": ["**/*"] 15 | }, 16 | { 17 | "from": "daemon", 18 | "to": "resources/daemon" 19 | }, 20 | { 21 | "from": "node_modules/better-sqlite3/build/Release/better_sqlite3.node", 22 | "to": "resources/better_sqlite3.node" 23 | }, 24 | { 25 | "from": "database/migrations", 26 | "to": "resources/migrations" 27 | } 28 | ], 29 | "linux": { 30 | "artifactName": "${productName}.${ext}", 31 | "category": "Utility", 32 | "target": ["appImage"], 33 | "executableName": "waypaper-engine-bin", 34 | "description": "A graphical frontend for setting wallpapers and playlists, using swww under the hood!", 35 | "desktop": { 36 | "Name": "Waypaper Engine", 37 | "Comment": "Waypaper Engine", 38 | "Terminal": "false", 39 | "Type": "Application", 40 | "Icon": "/build/icons/512x512.png" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /electron/electron-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare namespace NodeJS { 3 | interface ProcessEnv { 4 | /** 5 | * The built directory structure 6 | * 7 | * ```tree 8 | * ├─┬─┬ dist 9 | * │ │ └── index.html 10 | * │ │ 11 | * │ ├─┬ dist-electron 12 | * │ │ ├── main.js 13 | * │ │ └── preload.js 14 | * │ 15 | * ``` 16 | */ 17 | DIST: string; 18 | /** /dist/ or /public/ */ 19 | PUBLIC: string; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /electron/exposedApi.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { configuration } from "../globals/config"; 3 | import { 4 | type rendererImage, 5 | type rendererPlaylist 6 | } from "../src/types/rendererTypes"; 7 | import { join } from "node:path"; 8 | import { type openFileAction, type imagesObject } from "../shared/types"; 9 | import { type ActiveMonitor, type Monitor } from "../shared/types/monitor"; 10 | import { 11 | type playlistSelectType, 12 | type imageSelectType, 13 | type appConfigSelectType, 14 | type appConfigInsertType, 15 | type swwwConfigSelectType, 16 | type swwwConfigInsertType 17 | } from "../database/schema"; 18 | import { 19 | type IPC_MAIN_EVENTS_TYPE, 20 | type IPC_RENDERER_EVENTS_TYPE, 21 | type SHORTCUT_EVENTS_TYPE 22 | } from "../shared/constants"; 23 | export const ELECTRON_API = { 24 | openFiles: async (action: openFileAction) => 25 | await ipcRenderer.invoke("openFiles", action), 26 | handleOpenImages: async ( 27 | imagesObject: imagesObject 28 | ): Promise => { 29 | return await ipcRenderer.invoke("handleOpenImages", imagesObject); 30 | }, 31 | queryImages: async (): Promise => { 32 | return await ipcRenderer.invoke("queryImages"); 33 | }, 34 | setImage: (image: rendererImage, activeMonitor: ActiveMonitor) => { 35 | ipcRenderer.send("setImage", image, activeMonitor); 36 | }, 37 | setRandomImage: () => { 38 | ipcRenderer.send("setRandomImage"); 39 | }, 40 | savePlaylist: (playlistObject: rendererPlaylist) => { 41 | ipcRenderer.send("savePlaylist", playlistObject); 42 | }, 43 | startPlaylist: (playlist: { 44 | name: string; 45 | activeMonitor: ActiveMonitor; 46 | }) => { 47 | ipcRenderer.send("startPlaylist", playlist); 48 | }, 49 | queryPlaylists: async (): Promise => { 50 | return await ipcRenderer.invoke("queryPlaylists"); 51 | }, 52 | querySelectedMonitor: async (): Promise => { 53 | return await ipcRenderer.invoke("querySelectedMonitor"); 54 | }, 55 | setSelectedMonitor: (selectedMonitor: ActiveMonitor) => { 56 | ipcRenderer.send("setSelectedMonitor", selectedMonitor); 57 | }, 58 | getPlaylistImages: async (playlistID: number): Promise => { 59 | return await ipcRenderer.invoke("getPlaylistImages", playlistID); 60 | }, 61 | stopPlaylist: (playlist: { 62 | name: string; 63 | activeMonitor: ActiveMonitor; 64 | }) => { 65 | ipcRenderer.send("stopPlaylist", playlist); 66 | }, 67 | deleteImagesFromGallery: async (images: rendererImage[]) => { 68 | return await ipcRenderer.invoke("deleteImageFromGallery", images); 69 | }, 70 | deletePlaylist: (playlistName: string) => { 71 | ipcRenderer.send("deletePlaylist", playlistName); 72 | }, 73 | openContextMenu: ({ 74 | Image, 75 | selectedImagesLength 76 | }: { 77 | Image: rendererImage | undefined; 78 | selectedImagesLength: number; 79 | }) => { 80 | ipcRenderer.send("openContextMenuImage", Image, selectedImagesLength); 81 | }, 82 | updateSwwwConfig: (newConfig: swwwConfigInsertType["config"]) => { 83 | ipcRenderer.send("updateSwwwConfig", newConfig); 84 | }, 85 | readSwwwConfig: async (): Promise => { 86 | return await ipcRenderer.invoke("readSwwwConfig"); 87 | }, 88 | readAppConfig: async (): Promise => { 89 | return await ipcRenderer.invoke("readAppConfig"); 90 | }, 91 | updateAppConfig: (newAppConfig: appConfigInsertType["config"]) => { 92 | ipcRenderer.send("updateAppConfig", newAppConfig); 93 | }, 94 | 95 | readActivePlaylist: async ( 96 | monitor: ActiveMonitor 97 | ): Promise< 98 | (playlistSelectType & { images: rendererImage[] }) | undefined 99 | > => { 100 | return await ipcRenderer.invoke("readActivePlaylist", monitor); 101 | }, 102 | onClearPlaylist: ( 103 | callback: ( 104 | _: Electron.IpcRendererEvent, 105 | playlist: { 106 | name: string; 107 | activeMonitor: ActiveMonitor; 108 | } 109 | ) => void 110 | ) => { 111 | ipcRenderer.on("clearPlaylist", callback); 112 | }, 113 | onDeleteImageFromGallery: ( 114 | callback: ( 115 | _event: Electron.IpcRendererEvent, 116 | image: rendererImage 117 | ) => void 118 | ) => { 119 | ipcRenderer.on("deleteImageFromGallery", callback); 120 | }, 121 | onStartPlaylist: ( 122 | callback: (event: Electron.IpcRendererEvent, ...args: any[]) => void 123 | ) => { 124 | ipcRenderer.on("startPlaylist", callback); 125 | }, 126 | exitApp: () => { 127 | ipcRenderer.send("exitApp"); 128 | }, 129 | getMonitors: async () => { 130 | return await (ipcRenderer.invoke("getMonitors") as Promise); 131 | }, 132 | updateTray: () => { 133 | ipcRenderer.send("updateTray"); 134 | }, 135 | registerListener: ({ 136 | listener, 137 | channel 138 | }: { 139 | channel: 140 | | IPC_RENDERER_EVENTS_TYPE 141 | | SHORTCUT_EVENTS_TYPE 142 | | IPC_MAIN_EVENTS_TYPE; 143 | listener: (event: Electron.IpcRendererEvent, ...args: any[]) => void; 144 | }) => { 145 | const listeners = ipcRenderer.listeners(channel); 146 | if (listeners.length > 0) { 147 | ipcRenderer.removeAllListeners(channel); 148 | } 149 | ipcRenderer.addListener(channel, listener); 150 | }, 151 | getThumbnailSrc: (imageName: string) => { 152 | return ( 153 | "atom://" + 154 | join( 155 | configuration.directories.thumbnails, 156 | imageName.split(".")[0] + ".webp" 157 | ) 158 | ); 159 | }, 160 | getImageSrc: (imageName: string) => { 161 | return "atom://" + join(configuration.directories.imagesDir, imageName); 162 | } 163 | }; 164 | type ELECTRON_API_TYPE = typeof ELECTRON_API; 165 | 166 | declare global { 167 | interface Window { 168 | API_RENDERER: ELECTRON_API_TYPE; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /electron/playlistController.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { createConnection } from "net"; 3 | import { configuration } from "../globals/config"; 4 | import { ACTIONS, type message } from "../types/types"; 5 | import { type ActiveMonitor } from "../shared/types/monitor"; 6 | import { initWaypaperDaemon } from "../globals/startDaemons"; 7 | import { logger } from "../globals/setup"; 8 | const WAYPAPER_ENGINE_DAEMON_SOCKET_PATH = 9 | configuration.directories.WAYPAPER_ENGINE_DAEMON_SOCKET_PATH; 10 | export class PlaylistController extends EventEmitter { 11 | createTray: (() => Promise) | undefined; 12 | retries: number; 13 | constructor(trayReference?: () => Promise) { 14 | super(); 15 | this.createTray = trayReference; 16 | this.retries = 0; 17 | } 18 | 19 | async #sendData(data: message) { 20 | const connection = createConnection(WAYPAPER_ENGINE_DAEMON_SOCKET_PATH); 21 | connection.on("connect", () => { 22 | try { 23 | connection.write(JSON.stringify(data) + "\n", e => { 24 | // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 25 | if (e) { 26 | logger.error(e); 27 | return; 28 | } 29 | this.retries = 0; 30 | if (this.createTray !== undefined) void this.createTray(); 31 | }); 32 | } catch (error) { 33 | logger.error(error); 34 | } 35 | }); 36 | connection.on("data", _ => { 37 | try { 38 | if (this.createTray !== undefined) void this.createTray(); 39 | } catch (e) { 40 | logger.error(e); 41 | } 42 | }); 43 | connection.on("error", () => { 44 | if (this.retries > 3) throw new Error("Could not restart daemon"); 45 | this.retries++; 46 | void initWaypaperDaemon().then(() => { 47 | void this.#sendData(data); 48 | }); 49 | }); 50 | } 51 | 52 | startPlaylist(playlist: { name: string; activeMonitor: ActiveMonitor }) { 53 | void this.#sendData({ 54 | action: ACTIONS.START_PLAYLIST, 55 | playlist 56 | }); 57 | } 58 | 59 | pausePlaylist(playlist: { name: string; activeMonitor: ActiveMonitor }) { 60 | void this.#sendData({ 61 | action: ACTIONS.PAUSE_PLAYLIST, 62 | playlist 63 | }); 64 | } 65 | 66 | resumePlaylist(playlist: { name: string; activeMonitor: ActiveMonitor }) { 67 | void this.#sendData({ 68 | action: ACTIONS.RESUME_PLAYLIST, 69 | playlist 70 | }); 71 | } 72 | 73 | stopPlaylist(playlist: { name: string; activeMonitor: ActiveMonitor }) { 74 | void this.#sendData({ 75 | action: ACTIONS.STOP_PLAYLIST, 76 | playlist 77 | }); 78 | } 79 | 80 | stopPlaylistByName(playlistName: string) { 81 | void this.#sendData({ 82 | action: ACTIONS.STOP_PLAYLIST_BY_NAME, 83 | playlist: { 84 | name: playlistName 85 | } 86 | }); 87 | } 88 | 89 | getInfo() { 90 | void this.#sendData({ 91 | action: ACTIONS.GET_INFO 92 | }); 93 | } 94 | 95 | stopPlaylistByMonitorName(monitors: string[]) { 96 | void this.#sendData({ 97 | action: ACTIONS.STOP_PLAYLIST_BY_MONITOR_NAME, 98 | monitors 99 | }); 100 | } 101 | 102 | stopPlaylistOnRemovedMonitors() { 103 | void this.#sendData({ 104 | action: ACTIONS.STOP_PLAYLIST_ON_REMOVED_DISPLAYS 105 | }); 106 | } 107 | 108 | nextImage(playlist: { name: string; activeMonitor: ActiveMonitor }) { 109 | void this.#sendData({ 110 | action: ACTIONS.NEXT_IMAGE, 111 | playlist 112 | }); 113 | } 114 | 115 | previousImage(playlist: { name: string; activeMonitor: ActiveMonitor }) { 116 | void this.#sendData({ 117 | action: ACTIONS.PREVIOUS_IMAGE, 118 | playlist 119 | }); 120 | } 121 | 122 | randomImage() { 123 | void this.#sendData({ 124 | action: ACTIONS.RANDOM_IMAGE 125 | }); 126 | } 127 | 128 | killDaemon() { 129 | const daemonSocketConnection = createConnection( 130 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH 131 | ); 132 | daemonSocketConnection.write( 133 | JSON.stringify({ action: ACTIONS.STOP_DAEMON }), 134 | () => { 135 | daemonSocketConnection.destroy(); 136 | } 137 | ); 138 | } 139 | 140 | updateConfig() { 141 | void this.#sendData({ 142 | action: ACTIONS.UPDATE_CONFIG 143 | }); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /electron/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from "electron"; 2 | import { ELECTRON_API } from "./exposedApi"; 3 | 4 | contextBridge.exposeInMainWorld("API_RENDERER", ELECTRON_API); 5 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import pluginReact from "eslint-plugin-react"; 5 | 6 | export default [ 7 | { files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"] }, 8 | { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, 9 | pluginJs.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | pluginReact.configs.flat.recommended, 12 | { 13 | rules: { 14 | "@typescript-eslint/no-require-imports": "off", 15 | "react/react-in-jsx-scope": "off", 16 | "react/jsx-uses-react": "off" 17 | } 18 | }, 19 | { 20 | ignores: [ 21 | "**/dist/*", 22 | "*.js", 23 | "cli/*", 24 | "dist-electron/*", 25 | "**/node_modules/*", 26 | "release/*" 27 | ] 28 | }, 29 | { 30 | settings: { 31 | react: { 32 | version: "detect" 33 | } 34 | } 35 | } 36 | ]; 37 | -------------------------------------------------------------------------------- /globals/config.ts: -------------------------------------------------------------------------------- 1 | import { homedir } from "node:os"; 2 | import { join } from "node:path"; 3 | import { values } from "./setup"; 4 | import chokidar from "chokidar"; 5 | import { DBOperations } from "../database/dbOperations"; 6 | import { existsSync, mkdirSync, readdirSync } from "node:fs"; 7 | const systemHome = homedir(); 8 | const cacheDirectoryRoot = join(systemHome, ".cache", "waypaper_engine"); 9 | const cacheThumbnailsDirectory = join(cacheDirectoryRoot, "thumbnails"); 10 | const mainDirectory = join(systemHome, ".waypaper_engine"); 11 | const imagesDir = join(mainDirectory, "images"); 12 | const scriptsDir = join(mainDirectory, "scripts"); 13 | const extendedImages = join(cacheDirectoryRoot, "extended_images_cache"); 14 | const WAYPAPER_ENGINE_DAEMON_SOCKET_PATH = "/tmp/waypaper_engine_daemon.sock"; 15 | const WAYPAPER_ENGINE_SOCKET_PATH = "/tmp/waypaper_engine.sock"; 16 | const DAEMON_LOCK_FILE = "/tmp/waypaper-daemon.lock"; 17 | const DAEMON_PID = "waypaper-daemon"; 18 | const appDirectories = { 19 | systemHome, 20 | rootCache: cacheDirectoryRoot, 21 | thumbnails: cacheThumbnailsDirectory, 22 | mainDir: mainDirectory, 23 | imagesDir, 24 | scriptsDir, 25 | extendedImages, 26 | WAYPAPER_ENGINE_SOCKET_PATH, 27 | WAYPAPER_ENGINE_DAEMON_SOCKET_PATH, 28 | DAEMON_LOCK_FILE 29 | }; 30 | const dbOperations = new DBOperations(); 31 | let scripts: string[] = []; 32 | if (!existsSync(scriptsDir)) { 33 | mkdirSync(scriptsDir); 34 | } 35 | scripts = readdirSync(scriptsDir).map(fileName => { 36 | return join(scriptsDir, fileName); 37 | }); 38 | 39 | const configuration = { 40 | swww: { 41 | config: dbOperations.createSwwwConfigIfNotExists(), 42 | update: () => { 43 | configuration.swww.config = dbOperations.getSwwwConfig(); 44 | } 45 | }, 46 | app: { 47 | config: dbOperations.createAppConfigIfNotExists(), 48 | update: () => { 49 | configuration.app.config = dbOperations.getAppConfig(); 50 | } 51 | }, 52 | DAEMON_PID, 53 | directories: appDirectories, 54 | scripts, 55 | 56 | format: (values.format ?? false) as boolean, 57 | logs: (values.logs ?? false) as boolean 58 | }; 59 | const watcher = chokidar.watch(scriptsDir, { persistent: true }); 60 | watcher 61 | .on("add", () => { 62 | configuration.scripts = readdirSync(scriptsDir).map(fileName => { 63 | return join(scriptsDir, fileName); 64 | }); 65 | }) 66 | .on("remove", () => { 67 | configuration.scripts = readdirSync(scriptsDir).map(fileName => { 68 | return join(scriptsDir, fileName); 69 | }); 70 | }); 71 | export { configuration, dbOperations }; 72 | -------------------------------------------------------------------------------- /globals/setup.ts: -------------------------------------------------------------------------------- 1 | import { join, resolve } from "node:path"; 2 | import { parseArgs } from "node:util"; 3 | import { homedir } from "node:os"; 4 | import { mkdirSync, existsSync } from "node:fs"; 5 | import pino, { type Logger } from "pino"; 6 | const isPackaged = !(process.env.NODE_ENV === "development"); 7 | export const isDaemon = process.env.PROCESS === "daemon"; 8 | export const mainDirectory = join(homedir(), ".waypaper_engine"); 9 | if (!existsSync(mainDirectory)) { 10 | mkdirSync(mainDirectory); 11 | } 12 | const logPath = isDaemon 13 | ? join(mainDirectory, "daemon.log") 14 | : join(mainDirectory, "electron.log"); 15 | 16 | const resourcesPath = join(__dirname, "..", ".."); 17 | export const iconsPath = resolve( 18 | isPackaged 19 | ? join(resourcesPath, "./icons") 20 | : join(process.cwd(), "build/icons") 21 | ); 22 | export const daemonPath = resolve( 23 | isPackaged 24 | ? join(resourcesPath, "daemon", "dist", "daemon") 25 | : join(process.cwd(), "daemon", "dist", "daemon") 26 | ); 27 | 28 | export const nativeBindingPath = resolve( 29 | isPackaged 30 | ? join(resourcesPath, "better_sqlite3.node") 31 | : join( 32 | process.cwd(), 33 | "/node_modules/better-sqlite3/build/Release/better_sqlite3.node" 34 | ) 35 | ); 36 | 37 | export const migrationsPath = resolve( 38 | isPackaged 39 | ? join(resourcesPath, "migrations") 40 | : join(process.cwd(), "/database/migrations") 41 | ); 42 | 43 | export const migrationsPathDaemon = resolve( 44 | isPackaged 45 | ? join(resourcesPath, "..", "migrations") 46 | : join(process.cwd(), "..", "..", "..", "/database/migrations") 47 | ); 48 | export const { values } = parseArgs({ 49 | args: process.argv, 50 | options: { 51 | daemon: { 52 | type: "boolean", 53 | short: "d", 54 | default: false 55 | }, 56 | format: { 57 | type: "boolean", 58 | short: "f", 59 | default: false 60 | }, 61 | logs: { 62 | type: "boolean", 63 | short: "l", 64 | default: false 65 | } 66 | }, 67 | strict: false 68 | }); 69 | 70 | type customLogger = Console | Logger; 71 | 72 | export let logger: customLogger; 73 | 74 | if (values.logs === true) { 75 | const parentLogger = pino(pino.destination(logPath)); 76 | const pinoLogger = parentLogger.child({ 77 | module: isDaemon ? "daemon" : "electron" 78 | }); 79 | logger = pinoLogger; 80 | } else { 81 | logger = console; 82 | } 83 | 84 | process.on("uncaughtException", error => { 85 | logger.error(error); 86 | }); 87 | process.on("unhandledRejection", error => { 88 | logger.error(error); 89 | }); 90 | -------------------------------------------------------------------------------- /globals/startDaemons.ts: -------------------------------------------------------------------------------- 1 | import { execSync, spawn } from "child_process"; 2 | import { daemonPath, logger } from "./setup"; 3 | import { configuration } from "../globals/config"; 4 | import { createConnection, createServer } from "node:net"; 5 | import { type message } from "../types/types"; 6 | import { unlinkSync, writeFileSync } from "node:fs"; 7 | import EventEmitter from "node:events"; 8 | import { existsSync } from "fs"; 9 | import { promisify } from "node:util"; 10 | 11 | const setTimeoutPromise = promisify(setTimeout); 12 | function checkIfSwwwIsInstalled() { 13 | try { 14 | execSync(`swww --version`); 15 | console.info("swww is installed in the system"); 16 | } catch (error) { 17 | console.warn( 18 | "swww is not installed, please find instructions in the README.md on how to install it" 19 | ); 20 | logger.error(error); 21 | throw new Error("swww is not installed"); 22 | } 23 | } 24 | export function isSwwwRunning() { 25 | try { 26 | execSync('ps -A | grep "swww-daemon"'); 27 | return true; 28 | } catch (e) { 29 | return false; 30 | } 31 | } 32 | 33 | export function initSwwwDaemon() { 34 | checkIfSwwwIsInstalled(); 35 | try { 36 | if (configuration.format && isSwwwRunning()) { 37 | execSync("swww kill"); 38 | } 39 | const command = `swww-daemon --no-cache ${configuration.format ? "--format xrgb" : ""} &`; 40 | const output = spawn(command, { 41 | stdio: "ignore", 42 | shell: true, 43 | detached: true 44 | }); 45 | output.unref(); 46 | } catch (error) { 47 | logger.error(error); 48 | } 49 | } 50 | export function isWaypaperDaemonRunning() { 51 | try { 52 | execSync(`pidof ${configuration.DAEMON_PID}`); 53 | return existsSync(configuration.directories.DAEMON_LOCK_FILE); 54 | } catch (_err) { 55 | return false; 56 | } 57 | } 58 | export function acquireLock() { 59 | const lockFile = configuration.directories.DAEMON_LOCK_FILE; 60 | try { 61 | writeFileSync(lockFile, process.pid.toString(), { flag: "wx" }); 62 | return true; 63 | } catch (err) { 64 | // @ts-expect-error .code does exists 65 | if (err instanceof Error && err.code === "EEXIST") { 66 | return false; 67 | } 68 | throw err; 69 | } 70 | } 71 | 72 | export function releaseLock() { 73 | const lockFile = configuration.directories.DAEMON_LOCK_FILE; 74 | try { 75 | unlinkSync(lockFile); 76 | } catch (err) { 77 | // @ts-expect-error .code does exists 78 | if (err instanceof Error && err.code !== "ENOENT") { 79 | logger.error("Error releasing lock:", err); 80 | } 81 | } 82 | } 83 | 84 | export async function initWaypaperDaemon() { 85 | try { 86 | const args = [`${daemonPath}/daemon.js`]; 87 | if (configuration.format) { 88 | args.push(`--format`); 89 | } 90 | if (configuration.logs) { 91 | args.push(`--logs`); 92 | } 93 | const output = spawn("PROCESS=daemon node", args, { 94 | stdio: "ignore", 95 | shell: true, 96 | detached: true, 97 | env: { ...process.env } 98 | }); 99 | output.unref(); 100 | await testConnection(); 101 | } catch (error) { 102 | logger.error(error); 103 | logger.warn("Could not start waypaper-daemon, shutting down app..."); 104 | process.exit(1); 105 | } 106 | } 107 | async function testConnection() { 108 | const SOCKET_PATH = 109 | configuration.directories.WAYPAPER_ENGINE_DAEMON_SOCKET_PATH; 110 | const MAX_ATTEMPTS = 10; 111 | const RETRY_INTERVAL = 300; 112 | let attempt = 1; 113 | while (attempt <= MAX_ATTEMPTS) { 114 | try { 115 | await connectToDaemon(SOCKET_PATH); 116 | logger.info("Connection to waypaper daemon established."); 117 | return; 118 | } catch (error) { 119 | await setTimeoutPromise(RETRY_INTERVAL); 120 | attempt++; 121 | } 122 | } 123 | throw new Error("Failed to establish connection to waypaper daemon."); 124 | } 125 | 126 | async function connectToDaemon(socketPath: string) { 127 | return await new Promise((resolve, reject) => { 128 | try { 129 | const client = createConnection(socketPath, () => { 130 | client.end(); 131 | resolve(""); 132 | }); 133 | client.on("error", err => { 134 | reject(err); 135 | }); 136 | } catch (error) { 137 | logger.error(error); 138 | logger.error( 139 | "failed to test connection, this is because createConnection trhew" 140 | ); 141 | } 142 | }); 143 | } 144 | 145 | export function createMainServer() { 146 | const emitter = new EventEmitter(); 147 | const serverInstance = createServer(socket => { 148 | socket.on("data", buffer => { 149 | buffer 150 | .toString() 151 | .split("\n") 152 | .filter(message => message !== "") 153 | .forEach(message => { 154 | try { 155 | const parsedMessage: message = JSON.parse(message); 156 | emitter.emit(parsedMessage.action); 157 | } catch (error) { 158 | logger.error(error); 159 | } 160 | }); 161 | }); 162 | socket.on("error", err => { 163 | logger.error("Socket error:", err.message); 164 | }); 165 | }); 166 | serverInstance.on("error", err => { 167 | if (err.message.includes("EADDRINUSE")) { 168 | unlinkSync(configuration.directories.WAYPAPER_ENGINE_SOCKET_PATH); 169 | serverInstance.listen( 170 | configuration.directories.WAYPAPER_ENGINE_SOCKET_PATH 171 | ); 172 | } else { 173 | logger.error(err); 174 | throw err; 175 | } 176 | }); 177 | serverInstance.listen( 178 | configuration.directories.WAYPAPER_ENGINE_SOCKET_PATH 179 | ); 180 | return emitter; 181 | } 182 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Waypaper Engine 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "waypaper-engine", 3 | "private": true, 4 | "version": "2.0.4", 5 | "author": { 6 | "name": "0bCdian", 7 | "email": "diegoparranava@protonmail.com", 8 | "url": "https://github.com/0bCdian" 9 | }, 10 | "scripts": { 11 | "dev": "npm run compile_daemon && vite", 12 | "rebuild": "electron-rebuild --w node_modules/better-sqlite3", 13 | "compile_daemon": "tsc daemon/daemon.ts --outDir ./daemon/dist --target ES2020 --moduleResolution node --esModuleInterop --skipLibCheck --module CommonJs", 14 | "build": "cd daemon && npm install && cd .. && npm install && npm run rebuild && npm run compile_daemon && vite build && electron-builder", 15 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0 --fix", 16 | "db:extract_schema": "drizzle-kit introspect:sqlite", 17 | "db:generate": "drizzle-kit generate:sqlite", 18 | "db:studio": "drizzle-kit studio", 19 | "db:drop_migration": "drizzle-kit drop", 20 | "db:migrate": "tsc ./electron/database/migrations/migrate.ts --esModuleInterop", 21 | "preview": "vite preview" 22 | }, 23 | "dependencies": { 24 | "@dnd-kit/core": "6.3.1", 25 | "@dnd-kit/modifiers": "6.0.1", 26 | "@dnd-kit/sortable": "7.0.2", 27 | "@dnd-kit/utilities": "3.2.2", 28 | "better-sqlite3": "11.5.0", 29 | "chokidar": "4.0.1", 30 | "drizzle-orm": "0.30.8", 31 | "framer-motion": "10.13.1", 32 | "pino": "9.0.0", 33 | "react": "18.2.0", 34 | "node-addon-api": "8.3.0", 35 | "node-gyp": "11.0.0", 36 | "react-bezier-curve-editor": "1.1.2", 37 | "react-dom": "18.2.0", 38 | "react-hook-form": "7.53.0", 39 | "react-hotkeys-hook": "4.5.0", 40 | "react-responsive-pagination": "2.9.0", 41 | "react-router-dom": "6.15.0", 42 | "reflect-metadata": "0.2.2", 43 | "sharp": "0.33.5", 44 | "zustand": "4.5.5" 45 | }, 46 | "devDependencies": { 47 | "@electron/rebuild": "3.6.0", 48 | "@eslint/js": "9.15.0", 49 | "@originjs/vite-plugin-commonjs": "1.0.3", 50 | "@types/better-sqlite3": "7.6.9", 51 | "@types/node": "22.13.0", 52 | "@types/react": "18.0.37", 53 | "@types/react-dom": "18.0.11", 54 | "@types/validator": "13.12.2", 55 | "@vitejs/plugin-react": "4.3.1", 56 | "autoprefixer": "10.4.14", 57 | "daisyui": "4.7.2", 58 | "drizzle-kit": "0.20.14", 59 | "electron": "31.0.0", 60 | "electron-builder": "25.1.8", 61 | "eslint": "9.9.1", 62 | "eslint-plugin-react": "7.37.4", 63 | "globals": "15.9.0", 64 | "postcss": "8.4.41", 65 | "prettier": "3.3.3", 66 | "prettier-plugin-tailwindcss": "0.6.6", 67 | "tailwind-scrollbar": "3.1.0", 68 | "tailwindcss": "3.4.13", 69 | "typescript": "5.5.4", 70 | "typescript-eslint": "^8.23.0", 71 | "vite": "5.4.14", 72 | "vite-plugin-electron": "0.28.8", 73 | "vite-plugin-electron-renderer": "0.14.6" 74 | }, 75 | "main": "dist-electron/main.js", 76 | "homepage": "./" 77 | } 78 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /public/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/public/app.png -------------------------------------------------------------------------------- /readme_files/Waypaper_Engine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/readme_files/Waypaper_Engine.png -------------------------------------------------------------------------------- /readme_files/app_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/readme_files/app_settings.png -------------------------------------------------------------------------------- /readme_files/gallery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/readme_files/gallery.png -------------------------------------------------------------------------------- /readme_files/sidebar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/readme_files/sidebar.png -------------------------------------------------------------------------------- /readme_files/swww_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0bCdian/Waypaper-Engine/82eb64d461ef5ef3681d5d55793536f4d973359c/readme_files/swww_settings.png -------------------------------------------------------------------------------- /shared/constants.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type swwwConfig, 3 | FilterType, 4 | ResizeType, 5 | TransitionType, 6 | transitionPosition 7 | } from "./types/swww"; 8 | import { type appConfigType } from "./types/app"; 9 | import { type Formats } from "./types/image"; 10 | import { type objectValues } from "./types"; 11 | 12 | export const validImageExtensions: Formats[] = [ 13 | "jpeg", 14 | "jpg", 15 | "png", 16 | "gif", 17 | "bmp", 18 | "webp", 19 | "pnm", 20 | "tga", 21 | "tiff", 22 | "farbfeld" 23 | ]; 24 | 25 | export const initialAppConfig: appConfigType = { 26 | killDaemon: false, 27 | notifications: true, 28 | startMinimized: false, 29 | minimizeInsteadOfClose: false, 30 | randomImageMonitor: "clone", 31 | showMonitorModalOnStart: true, 32 | imagesPerPage: 20 33 | }; 34 | 35 | export const initialSwwwConfig: swwwConfig = { 36 | resizeType: ResizeType.crop, 37 | fillColor: "#000000", 38 | filterType: FilterType.Lanczos3, 39 | transitionType: TransitionType.simple, 40 | transitionStep: 90, 41 | transitionDuration: 3, 42 | transitionFPS: 60, 43 | transitionAngle: 45, 44 | transitionPositionType: "alias", 45 | transitionPosition: transitionPosition.center, 46 | transitionPositionIntX: 960, 47 | transitionPositionIntY: 540, 48 | transitionPositionFloatX: 0.5, 49 | transitionPositionFloatY: 0.5, 50 | invertY: false, 51 | transitionBezier: ".25,.1,.25,1", 52 | transitionWaveX: 20, 53 | transitionWaveY: 20 54 | }; 55 | 56 | export const SHORTCUT_EVENTS = { 57 | selectAllImagesInCurrentPage: "selectAllImagesInCurrentPage", 58 | clearSelection: "clearSelection", 59 | selectAllImagesInGallery: "selectAllImagesInGallery" 60 | } as const; 61 | 62 | export type SHORTCUT_EVENTS_TYPE = objectValues; 63 | export const MENU_EVENTS = { 64 | selectAllImagesInGallery: "selectAllImagesInGallery", 65 | selectAllImagesInCurrentPage: "selectAllImagesInCurrentPage", 66 | clearSelectionOnCurrentPage: "clearSelectionOnCurrentPage", 67 | clearSelection: "clearSelection", 68 | setImagesPerPage: "setImagesPerPage", 69 | addSelectedImagesToPlaylist: "addSelectedImagesToPlaylist", 70 | deleteAllSelectedImages: "deleteAllSelectedImages", 71 | removeSelectedImagesFromPlaylist: "removeSelectedImagesFromPlaylist", 72 | deleteImageFromGallery: "deleteImageFromGallery" 73 | } as const; 74 | 75 | export type IPC_RENDERER_EVENTS_TYPE = objectValues; 76 | 77 | export const IPC_MAIN_EVENTS = { 78 | updateAppConfig: "updateAppConfig", 79 | displaysChanged: "displaysChanged", 80 | clearPlaylist: "clearPlaylist", 81 | requeryPlaylist: "requeryPlaylist" 82 | } as const; 83 | 84 | export type IPC_MAIN_EVENTS_TYPE = objectValues; 85 | -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | import { type Monitor } from "./types/monitor"; 2 | 3 | export type openFileAction = "file" | "folder"; 4 | 5 | export interface imagesObject { 6 | imagePaths: string[]; 7 | fileNames: string[]; 8 | } 9 | 10 | export type objectValues = T[keyof T]; 11 | 12 | export interface CacheJSON { 13 | imageName: string; 14 | monitors: Monitor[]; 15 | } 16 | -------------------------------------------------------------------------------- /shared/types/app.ts: -------------------------------------------------------------------------------- 1 | export interface appConfigType { 2 | killDaemon: boolean; 3 | notifications: boolean; 4 | startMinimized: boolean; 5 | minimizeInsteadOfClose: boolean; 6 | randomImageMonitor: "clone" | "extend" | "individual"; 7 | showMonitorModalOnStart: boolean; 8 | imagesPerPage: number; 9 | } 10 | -------------------------------------------------------------------------------- /shared/types/image.ts: -------------------------------------------------------------------------------- 1 | export interface Image { 2 | width: number; 3 | height: number; 4 | format: string; 5 | name: string; 6 | } 7 | 8 | export type Formats = 9 | | "jpg" 10 | | "jpeg" 11 | | "png" 12 | | "bmp" 13 | | "gif" 14 | | "webp" 15 | | "farbfeld" 16 | | "pnm" 17 | | "tga" 18 | | "tiff"; 19 | -------------------------------------------------------------------------------- /shared/types/monitor.ts: -------------------------------------------------------------------------------- 1 | export interface wlr_randr_monitor { 2 | name: string; 3 | description: string; 4 | make: string; 5 | model: string; 6 | serial: string; 7 | physical_size: { 8 | width: number; 9 | height: number; 10 | }; 11 | enabled: boolean; 12 | modes: Array<{ 13 | width: number; 14 | height: number; 15 | refresh: number; 16 | preferred: boolean; 17 | current: boolean; 18 | }>; 19 | position: { 20 | x: number; 21 | y: number; 22 | }; 23 | transform: string; 24 | scale: number; 25 | adaptive_sync: boolean; 26 | } 27 | 28 | export type wlr_output = wlr_randr_monitor[]; 29 | 30 | export interface Monitor { 31 | name: string; 32 | width: number; 33 | height: number; 34 | currentImage: string; 35 | position: { 36 | x: number; 37 | y: number; 38 | }; 39 | } 40 | 41 | export interface ActiveMonitor { 42 | name: string; 43 | monitors: Monitor[]; 44 | extendAcrossMonitors: boolean; 45 | } 46 | -------------------------------------------------------------------------------- /shared/types/playlist.ts: -------------------------------------------------------------------------------- 1 | import { type objectValues } from "../types"; 2 | 3 | export const PLAYLIST_ORDER = { 4 | ordered: "ordered", 5 | random: "random" 6 | } as const; 7 | 8 | export const PLAYLIST_TYPES = { 9 | TIMER: "timer", 10 | NEVER: "never", 11 | TIME_OF_DAY: "timeofday", 12 | DAY_OF_WEEK: "dayofweek" 13 | } as const; 14 | 15 | export type PLAYLIST_ORDER_TYPES = objectValues; 16 | export type PLAYLIST_TYPES_TYPE = objectValues; 17 | -------------------------------------------------------------------------------- /shared/types/swww.ts: -------------------------------------------------------------------------------- 1 | export enum ResizeType { 2 | crop = "crop", 3 | fit = "fit", 4 | none = "no" 5 | } 6 | export enum FilterType { 7 | Lanczos3 = "Lanczos3", 8 | Bilinear = "Bilinear", 9 | CatmullRom = "CatmullRom", 10 | Mitchell = "Mitchell", 11 | Nearest = "Nearest" 12 | } 13 | export enum TransitionType { 14 | none = "none", 15 | simple = "simple", 16 | fade = "fade", 17 | left = "left", 18 | right = "right", 19 | top = "top", 20 | bottom = "bottom", 21 | wipe = "wipe", 22 | wave = "wave", 23 | grow = "grow", 24 | center = "center", 25 | any = "any", 26 | outer = "outer", 27 | random = "random" 28 | } 29 | 30 | export enum transitionPosition { 31 | center = "center", 32 | top = "top", 33 | left = "left", 34 | right = "right", 35 | bottom = "bottom", 36 | topLeft = "top-left", 37 | topRight = "top-right", 38 | bottomLeft = "bottom-left", 39 | bottomRight = "bottom-right" 40 | } 41 | export type transitionPositionType = "alias" | "int" | "float"; 42 | export interface swwwConfig { 43 | resizeType: ResizeType; 44 | fillColor: string; 45 | filterType: FilterType; 46 | transitionType: TransitionType; 47 | transitionStep: number; 48 | transitionDuration: number; 49 | transitionFPS: number; 50 | transitionAngle: number; 51 | transitionPositionType: transitionPositionType; 52 | transitionPosition: transitionPosition; 53 | transitionPositionIntX: number; 54 | transitionPositionIntY: number; 55 | transitionPositionFloatX: number; 56 | transitionPositionFloatY: number; 57 | invertY: boolean; 58 | transitionBezier: string; 59 | transitionWaveX: number; 60 | transitionWaveY: number; 61 | } 62 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route, HashRouter } from "react-router-dom"; 2 | import SwwwConfig from "./routes/SwwwConfig"; 3 | import AppConfiguration from "./routes/AppConfiguration"; 4 | import Drawer from "./components/Drawer"; 5 | import NavBar from "./components/NavBar"; 6 | import Home from "./routes/Home"; 7 | import { useLoadAppConfig } from "./hooks/useLoadAppConfig"; 8 | const App = () => { 9 | useLoadAppConfig()(); 10 | return ( 11 | 12 | 13 | 14 | 15 | } /> 16 | } /> 17 | } /> 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default App; 25 | -------------------------------------------------------------------------------- /src/components/AddFoldersIcon.tsx: -------------------------------------------------------------------------------- 1 | import { type FC } from "react"; 2 | const SvgComponentFolder: FC = () => ( 3 | 10 | 11 | 16 | 17 | {" "} 18 | {" "} 25 | {" "} 32 | 33 | 34 | ); 35 | export default SvgComponentFolder; 36 | -------------------------------------------------------------------------------- /src/components/AddImagesCard.tsx: -------------------------------------------------------------------------------- 1 | import SvgComponent from "./addImagesIcon"; 2 | import SvgComponentFolder from "./AddFoldersIcon"; 3 | import openImagesStore from "../hooks/useOpenImages"; 4 | import { playlistStore } from "../stores/playlist"; 5 | import { imagesStore } from "../stores/images"; 6 | import { type openFileAction } from "../../shared/types"; 7 | import { useCallback } from "react"; 8 | 9 | function AddImagesCard() { 10 | const { openImages, isActive } = openImagesStore(); 11 | const { setSkeletons, addImages } = imagesStore(); 12 | const { addImagesToPlaylist } = playlistStore(); 13 | const handleClickAddImages = useCallback((action: openFileAction) => { 14 | void openImages({ 15 | setSkeletons, 16 | addImages, 17 | addImagesToPlaylist, 18 | action 19 | }); 20 | }, []); 21 | 22 | return ( 23 |
24 |
{ 30 | handleClickAddImages("file"); 31 | } 32 | } 33 | > 34 |
35 | 36 |
37 |

38 | Add individual images 39 |

40 |
41 |
{ 47 | handleClickAddImages("folder"); 48 | } 49 | } 50 | > 51 |
52 | 53 |
54 |

55 | Add images from directory 56 |

57 |
58 |
59 | ); 60 | } 61 | 62 | export default AddImagesCard; 63 | -------------------------------------------------------------------------------- /src/components/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import { type FC, useState } from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | interface Props { 5 | children: React.ReactNode; 6 | } 7 | const { exitApp } = window.API_RENDERER; 8 | const Drawer: FC = ({ children }) => { 9 | const [show, setShow] = useState(false); 10 | const toggle = () => { 11 | setShow(prev => !prev); 12 | }; 13 | return ( 14 |
15 | 22 |
23 | {children} 24 |
25 |
26 | 27 | 67 |
68 |
69 | ); 70 | }; 71 | 72 | export default Drawer; 73 | -------------------------------------------------------------------------------- /src/components/Filters.tsx: -------------------------------------------------------------------------------- 1 | import { type ChangeEvent, useEffect, useState } from "react"; 2 | import useDebounce from "../hooks/useDebounce"; 3 | import { type Filters as FiltersType } from "../types/rendererTypes"; 4 | import { imagesStore } from "../stores/images"; 5 | interface PartialFilters { 6 | order: "asc" | "desc"; 7 | type: "name" | "id"; 8 | searchString: string; 9 | } 10 | const initialFilters: PartialFilters = { 11 | order: "desc", 12 | type: "id", 13 | searchString: "" 14 | }; 15 | function Filters() { 16 | const { setFilters, filters } = imagesStore(); 17 | const [partialFilters, setPartialFilters] = useState(initialFilters); 18 | const onTextChange = (event: ChangeEvent) => { 19 | const target = event.target; 20 | if (target !== null) { 21 | const text = target.value; 22 | setPartialFilters((previous: PartialFilters) => { 23 | return { ...previous, searchString: text }; 24 | }); 25 | } 26 | }; 27 | useDebounce( 28 | () => { 29 | const newFilters: FiltersType = { 30 | ...partialFilters, 31 | advancedFilters: filters.advancedFilters 32 | }; 33 | setFilters(newFilters); 34 | }, 35 | 200, 36 | [partialFilters] 37 | ); 38 | useEffect(() => { 39 | const resetFilters: FiltersType = { 40 | ...partialFilters, 41 | advancedFilters: filters.advancedFilters 42 | }; 43 | setFilters(resetFilters); 44 | }, []); 45 | return ( 46 |
47 |
48 | 57 |
58 |
59 | 73 |
74 |
75 | 89 |
90 | 97 |
98 | ); 99 | } 100 | 101 | export default Filters; 102 | -------------------------------------------------------------------------------- /src/components/Gallery.tsx: -------------------------------------------------------------------------------- 1 | import { useShallow } from "zustand/react/shallow"; 2 | import { useLoadImages } from "../hooks/useLoadImages"; 3 | import { imagesStore } from "../stores/images"; 4 | import AddImagesCard from "./AddImagesCard"; 5 | import PaginatedGallery from "./PaginatedGallery"; 6 | import Filters from "./Filters"; 7 | function Gallery() { 8 | const isEmpty = imagesStore(useShallow(state => state.isEmpty)); 9 | const isQueried = imagesStore(useShallow(state => state.isQueried)); 10 | useLoadImages()(); 11 | if (isEmpty && isQueried) 12 | return ( 13 |
14 | 15 |
16 | ); 17 | return ( 18 | <> 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | export default Gallery; 26 | -------------------------------------------------------------------------------- /src/components/ImageCard.tsx: -------------------------------------------------------------------------------- 1 | import { type ChangeEvent, useState, useEffect } from "react"; 2 | import { playlistStore } from "../stores/playlist"; 3 | import { motion } from "framer-motion"; 4 | import { type rendererImage } from "../types/rendererTypes"; 5 | import { imagesStore } from "../stores/images"; 6 | import { useShallow } from "zustand/react/shallow"; 7 | import { isHotkeyPressed } from "react-hotkeys-hook"; 8 | import { useMonitorStore } from "../stores/monitors"; 9 | interface ImageCardProps { 10 | Image: rendererImage; 11 | } 12 | const { setImage, openContextMenu, getImageSrc, getThumbnailSrc } = 13 | window.API_RENDERER; 14 | function ImageCard({ Image }: ImageCardProps) { 15 | const [selected, setSelected] = useState(Image.isSelected); 16 | const [isChecked, setIsChecked] = useState(Image.isChecked); 17 | const imageNameFilePath = getThumbnailSrc(Image.name); 18 | const { activeMonitor } = useMonitorStore(); 19 | const handleDoubleClick = () => { 20 | setImage(Image, activeMonitor); 21 | }; 22 | const addImageToPlaylist = playlistStore( 23 | useShallow(state => state.addImagesToPlaylist) 24 | ); 25 | const readPlaylist = playlistStore(useShallow(state => state.readPlaylist)); 26 | const removeImageFromPlaylist = playlistStore( 27 | useShallow(state => state.removeImagesFromPlaylist) 28 | ); 29 | const isEmpty = playlistStore(useShallow(state => state.isEmpty)); 30 | const imagesInPlaylist = playlistStore( 31 | useShallow(state => state.playlistImagesSet) 32 | ); 33 | const { addToSelectedImages, removeFromSelectedImages, selectedImages } = 34 | imagesStore(); 35 | const handleCheckboxChange = (event: ChangeEvent) => { 36 | event.stopPropagation(); 37 | const element = event.target as HTMLInputElement; 38 | if (element.checked) { 39 | const playlist = readPlaylist(); 40 | if ( 41 | playlist.configuration.type === "dayofweek" && 42 | playlist.images.length === 7 43 | ) { 44 | setIsChecked(false); 45 | element.checked = false; 46 | return; 47 | } 48 | setIsChecked(true); 49 | Image.isChecked = true; 50 | addImageToPlaylist([Image]); 51 | } else { 52 | Image.isChecked = false; 53 | setIsChecked(false); 54 | removeImageFromPlaylist(new Set().add(Image.id)); 55 | } 56 | }; 57 | const handleRightClick = (e: React.MouseEvent) => { 58 | e.stopPropagation(); 59 | openContextMenu({ Image, selectedImagesLength: selectedImages.size }); 60 | }; 61 | useEffect(() => { 62 | if (selected) addToSelectedImages(Image); 63 | else removeFromSelectedImages(Image); 64 | }, [selected]); 65 | useEffect(() => { 66 | if (imagesInPlaylist.has(Image.id) && !isEmpty) { 67 | setIsChecked(true); 68 | Image.isChecked = true; 69 | return; 70 | } 71 | setIsChecked(false); 72 | Image.isChecked = false; 73 | }, [isEmpty, imagesInPlaylist]); 74 | useEffect(() => { 75 | if (selectedImages.size === 0) { 76 | setSelected(false); 77 | return; 78 | } 79 | setSelected(selectedImages.has(Image.id)); 80 | }, [selectedImages.size]); 81 | return ( 82 | { 88 | e.stopPropagation(); 89 | if (!isHotkeyPressed("ctrl")) return; 90 | setSelected(prev => { 91 | Image.isSelected = !prev; 92 | return !prev; 93 | }); 94 | }} 95 | > 96 |
97 | 104 |
105 |
106 | {Image.name} { 113 | currentTarget.onerror = null; 114 | currentTarget.className = 115 | "rounded-lg min-w-full max-w-[300px] object-fill"; 116 | currentTarget.src = getImageSrc(Image.name); 117 | }} 118 | /> 119 |

120 | {Image.name} 121 |

122 |
127 |
128 |
129 | ); 130 | } 131 | 132 | export default ImageCard; 133 | -------------------------------------------------------------------------------- /src/components/IntroScreen.tsx: -------------------------------------------------------------------------------- 1 | import { motion, AnimatePresence } from "framer-motion"; 2 | import { useEffect, useState } from "react"; 3 | 4 | function IntroScreen() { 5 | const [showIntro, setShowIntro] = useState(true); 6 | useEffect(() => { 7 | setTimeout(() => { 8 | setShowIntro(false); 9 | }, 2500); 10 | }, []); 11 | 12 | return ( 13 | 14 | {showIntro && ( 15 | 24 | 25 | Waypaper Engine 26 | 27 | 28 | )} 29 | 30 | ); 31 | } 32 | 33 | export default IntroScreen; 34 | -------------------------------------------------------------------------------- /src/components/MiniPlaylistCard.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from "@dnd-kit/sortable"; 2 | import { useEffect, useMemo, useRef, useCallback, useState, memo } from "react"; 3 | import { type PLAYLIST_TYPES_TYPE } from "../../shared/types/playlist"; 4 | import { playlistStore } from "../stores/playlist"; 5 | import { type rendererImage } from "../types/rendererTypes"; 6 | import { motion } from "framer-motion"; 7 | import useDebounceCallback from "../hooks/useDebounceCallback"; 8 | const { getThumbnailSrc } = window.API_RENDERER; 9 | let firstRender = true; 10 | const daysOfWeek = [ 11 | "Sunday", 12 | "Monday", 13 | "Tuesday", 14 | "Wednesday", 15 | "Thursday", 16 | "Friday", 17 | "Saturday" 18 | ]; 19 | const MiniPlaylistCard = memo(function MiniPlaylistCard({ 20 | Image, 21 | type, 22 | index, 23 | isLast, 24 | reorderSortingCriteria 25 | }: { 26 | Image: rendererImage; 27 | type: PLAYLIST_TYPES_TYPE; 28 | index: number; 29 | isLast: boolean; 30 | reorderSortingCriteria: () => void; 31 | }) { 32 | const { removeImagesFromPlaylist, playlistImagesTimeSet } = playlistStore(); 33 | const [isInvalid, setIsInvalid] = useState(false); 34 | const imageRef = useRef(null); 35 | const timeRef = useRef(null); 36 | const imageSrc = useMemo(() => { 37 | return getThumbnailSrc(Image.name); 38 | }, [Image]); 39 | const { attributes, listeners, setNodeRef } = useSortable({ 40 | id: Image.id 41 | }); 42 | let text: string; 43 | if (isLast === undefined) { 44 | if (index < 6) { 45 | text = `${daysOfWeek[index]}-Sunday`; 46 | } else { 47 | text = daysOfWeek[index]; 48 | } 49 | } else { 50 | text = daysOfWeek[index]; 51 | } 52 | const onRemove = useCallback(() => { 53 | Image.isChecked = false; 54 | removeImagesFromPlaylist(new Set().add(Image.id)); 55 | }, []); 56 | 57 | const reOrderDebounced = useDebounceCallback(() => { 58 | reorderSortingCriteria(); 59 | }, 200); 60 | useEffect(() => { 61 | if ( 62 | timeRef.current !== null && 63 | Image.time !== null && 64 | type === "timeofday" 65 | ) { 66 | let minutes: string | number = Image.time % 60; 67 | let hours: string | number = (Image.time - minutes) / 60; 68 | minutes = minutes < 10 ? "0" + minutes : minutes; 69 | hours = hours < 10 ? "0" + hours : hours; 70 | timeRef.current.value = `${hours}:${minutes}`; 71 | } 72 | }, [type, Image.time, playlistImagesTimeSet]); 73 | 74 | useEffect(() => { 75 | if (firstRender) { 76 | firstRender = false; 77 | return; 78 | } 79 | if (isLast) { 80 | setTimeout(() => { 81 | imageRef.current?.scrollIntoView({ 82 | behavior: "smooth" 83 | }); 84 | }, 500); 85 | } 86 | }, [index]); 87 | return ( 88 | 97 |
98 | {type === "timeofday" && ( 99 |
100 | 107 | Invalid time 108 | 109 | { 114 | const stringValue = e.currentTarget.value; 115 | const [hours, minutes] = stringValue.split(":"); 116 | const newTimeSum = 117 | Number(hours) * 60 + Number(minutes); 118 | if (playlistImagesTimeSet.has(newTimeSum)) { 119 | e.currentTarget.setCustomValidity( 120 | "invalid time, another image has the same time" 121 | ); 122 | setIsInvalid(true); 123 | } else { 124 | e.currentTarget.setCustomValidity(""); 125 | setIsInvalid(false); 126 | playlistImagesTimeSet.delete( 127 | Image.time ?? -1 128 | ); 129 | Image.time = newTimeSum; 130 | playlistImagesTimeSet.add(newTimeSum); 131 | reOrderDebounced(); 132 | } 133 | }} 134 | /> 135 |
136 | )} 137 | 138 | {type === "dayofweek" ? text : undefined} 139 | 140 |
141 | 160 |
161 | {Image.name} 170 |
171 |
172 | ); 173 | }); 174 | 175 | export default MiniPlaylistCard; 176 | -------------------------------------------------------------------------------- /src/components/Modals.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import LoadPlaylistModal from "./LoadPlaylistModal"; 3 | import SavePlaylistModal from "./SavePlaylistModal"; 4 | import PlaylistConfigurationModal from "./PlaylistConfigurationModal"; 5 | import { playlistStore } from "../stores/playlist"; 6 | import { imagesStore } from "../stores/images"; 7 | import AdvancedFiltersModal from "./AdvancedFiltersModal"; 8 | import { type playlistSelectType } from "../../database/schema"; 9 | import { useAppConfigStore } from "../stores/appConfig"; 10 | import Monitors from "./monitorsModal"; 11 | import { useMonitorStore } from "../stores/monitors"; 12 | const { queryPlaylists } = window.API_RENDERER; 13 | let alreadyShown = false; 14 | function Modals() { 15 | const [playlistsInDB, setPlaylistsInDB] = useState( 16 | [] 17 | ); 18 | const { appConfig, isSetup } = useAppConfigStore(); 19 | const { setLastSavedMonitorConfig, reQueryMonitors } = useMonitorStore(); 20 | useEffect(() => { 21 | if (alreadyShown) return; 22 | alreadyShown = true; 23 | void setLastSavedMonitorConfig().then(() => { 24 | if (!isSetup || !appConfig.showMonitorModalOnStart) return; 25 | setTimeout(() => { 26 | void reQueryMonitors().then(() => { 27 | // @ts-expect-error daisy-ui 28 | window.monitors.showModal(); 29 | }); 30 | }, 300); 31 | }); 32 | }, []); 33 | 34 | const [shouldReload, setShouldReload] = useState(false); 35 | const { playlist } = playlistStore(); 36 | const { imagesArray } = imagesStore(); 37 | useEffect(() => { 38 | setShouldReload(false); 39 | void queryPlaylists().then(playlists => { 40 | setPlaylistsInDB(playlists); 41 | }); 42 | }, [shouldReload, imagesArray]); 43 | useEffect(() => { 44 | void queryPlaylists().then(newPlaylists => { 45 | setPlaylistsInDB(newPlaylists); 46 | }); 47 | }, []); 48 | return ( 49 | <> 50 | 55 | 59 | 60 | 61 | 62 | 63 | ); 64 | } 65 | 66 | export default Modals; 67 | -------------------------------------------------------------------------------- /src/components/Monitor.tsx: -------------------------------------------------------------------------------- 1 | import { useMonitorStore, type StoreMonitor } from "../stores/monitors"; 2 | import { type monitorSelectType } from "../types/rendererTypes"; 3 | 4 | interface props { 5 | monitor: StoreMonitor; 6 | scale: number; 7 | selectType: monitorSelectType; 8 | monitorsList: StoreMonitor[]; 9 | } 10 | 11 | export function MonitorComponent({ 12 | monitor, 13 | scale, 14 | selectType, 15 | monitorsList 16 | }: props) { 17 | const { setMonitorsList } = useMonitorStore(); 18 | const scaledWidth = monitor.width * scale; 19 | const scaledHeight = monitor.height * scale; 20 | const rectangleStyle: React.CSSProperties = { 21 | width: scaledWidth, 22 | height: scaledHeight, 23 | position: "relative" 24 | }; 25 | const imageStyle: React.CSSProperties = { 26 | width: "100%", 27 | height: "100%", 28 | objectFit: "cover" 29 | }; 30 | return ( 31 |
{ 33 | if (monitorsList.length < 1) return; 34 | monitor.isSelected = !monitor.isSelected; 35 | if (selectType === "individual") { 36 | monitorsList.forEach(otherMonitor => { 37 | if (otherMonitor.name !== monitor.name) { 38 | otherMonitor.isSelected = false; 39 | } 40 | }); 41 | } 42 | setMonitorsList([...monitorsList]); 43 | }} 44 | className="relative select-none rounded-lg" 45 | draggable={false} 46 | > 47 |
53 | Monitor 61 |
65 | {monitor.name} 66 |
67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { useMonitorStore } from "../stores/monitors"; 2 | const NavBar = () => { 3 | const { activeMonitor, reQueryMonitors } = useMonitorStore(); 4 | return ( 5 |
6 |
7 |
8 | 28 |
29 |
30 |
31 | { 32 | 45 | } 46 |
47 |
48 |
49 | ); 50 | }; 51 | 52 | export default NavBar; 53 | -------------------------------------------------------------------------------- /src/components/PaginatedGallery.tsx: -------------------------------------------------------------------------------- 1 | import ResponsivePagination from "react-responsive-pagination"; 2 | import "react-responsive-pagination/themes/minimal.css"; 3 | import "../custom.css"; 4 | import PlaylistTrack from "./PlaylistTrack"; 5 | import { useImagePagination } from "../hooks/useImagePagination"; 6 | import { motion, AnimatePresence } from "framer-motion"; 7 | import { useRef } from "react"; 8 | const { openContextMenu } = window.API_RENDERER; 9 | 10 | function PaginatedGallery() { 11 | const { 12 | imagesToShow, 13 | handlePageChange, 14 | currentPage, 15 | totalPages, 16 | selectedImages 17 | } = useImagePagination(); 18 | const ref = useRef(null); 19 | return ( 20 | 21 | { 24 | ref.current?.focus(); 25 | }} 26 | tabIndex={-1} 27 | initial={{ opacity: 0 }} 28 | animate={{ opacity: 1 }} 29 | exit={{ opacity: 0 }} 30 | transition={{ duration: 0.5 }} 31 | className="m-auto flex max-h-[84vh] min-h-[86vh] flex-col justify-between gap-4 overflow-y-hidden transition focus:outline-none sm:w-[90%]" 32 | onContextMenu={e => { 33 | e.stopPropagation(); 34 | openContextMenu({ 35 | Image: undefined, 36 | selectedImagesLength: selectedImages.size 37 | }); 38 | }} 39 | > 40 |
41 |
48 | {imagesToShow} 49 |
50 |
51 |
52 |
53 | { 59 | handlePageChange(page); 60 | }} 61 | /> 62 |
63 | 64 |
65 |
66 |
67 | ); 68 | } 69 | 70 | export default PaginatedGallery; 71 | -------------------------------------------------------------------------------- /src/components/SavePlaylistModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { useForm, type SubmitHandler } from "react-hook-form"; 3 | import { playlistStore } from "../stores/playlist"; 4 | import { type rendererImage } from "../types/rendererTypes"; 5 | import { useMonitorStore } from "../stores/monitors"; 6 | const { savePlaylist } = window.API_RENDERER; 7 | 8 | interface Props { 9 | currentPlaylistName: string; 10 | setShouldReload: React.Dispatch>; 11 | } 12 | interface savePlaylistModalFields { 13 | playlistName: string; 14 | } 15 | const SavePlaylistModal = ({ currentPlaylistName, setShouldReload }: Props) => { 16 | const { setName, readPlaylist } = playlistStore(); 17 | const [error, showError] = useState({ state: false, message: "" }); 18 | const { activeMonitor } = useMonitorStore(); 19 | const modalRef = useRef(null); 20 | const { register, handleSubmit, setValue } = 21 | useForm(); 22 | const closeModal = () => { 23 | modalRef.current?.close(); 24 | }; 25 | const checkDuplicateTimes = (Images: rendererImage[]) => { 26 | let duplicatesExist = false; 27 | const maxImageIndex = Images.length; 28 | // impossible value to get from the input time in miniplaylist card 29 | let lastTime = -1; 30 | for (let current = 0; current < maxImageIndex; current++) { 31 | if (Images[current].time === lastTime) { 32 | duplicatesExist = true; 33 | } else { 34 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 35 | lastTime = Images[current].time!; 36 | } 37 | } 38 | return duplicatesExist; 39 | }; 40 | const onSubmit: SubmitHandler = data => { 41 | setName(data.playlistName); 42 | const playlist = readPlaylist(); 43 | if (playlist.configuration.type === "timeofday") { 44 | if (checkDuplicateTimes(playlist.images)) { 45 | showError({ 46 | state: true, 47 | message: 48 | "There are duplicate times in images, check them before resubmitting." 49 | }); 50 | return; 51 | } else { 52 | showError({ state: false, message: "" }); 53 | } 54 | } 55 | if (activeMonitor.monitors.length < 1 || activeMonitor.name === "") { 56 | showError({ 57 | state: true, 58 | message: "Select at least one monitor to save playlist." 59 | }); 60 | setTimeout(() => { 61 | showError({ state: false, message: "" }); 62 | }, 3000); 63 | return; 64 | } 65 | playlist.activeMonitor = activeMonitor; 66 | savePlaylist(playlist); 67 | setShouldReload(true); 68 | closeModal(); 69 | }; 70 | useEffect(() => { 71 | setValue("playlistName", currentPlaylistName); 72 | }, [currentPlaylistName]); 73 | return ( 74 | 80 |
{ 82 | void handleSubmit(onSubmit)(e); 83 | }} 84 | className="form-control modal-box rounded-xl" 85 | > 86 |

87 | Save Playlist 88 |

89 |
90 | 96 | 97 | 106 |
107 | {error.state && ( 108 | 114 | )} 115 | 121 |
122 |
123 | 124 |
125 |
126 | ); 127 | }; 128 | 129 | export default SavePlaylistModal; 130 | -------------------------------------------------------------------------------- /src/components/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | interface SkeletonProps { 2 | imageName: string; 3 | } 4 | 5 | function Skeleton({ imageName }: SkeletonProps) { 6 | return ( 7 |
8 | 9 |

10 | {imageName} 11 |

12 |
13 | ); 14 | } 15 | 16 | export default Skeleton; 17 | -------------------------------------------------------------------------------- /src/components/addImagesIcon.tsx: -------------------------------------------------------------------------------- 1 | import { type FC } from "react"; 2 | const SvgComponent: FC = () => ( 3 | 11 | 17 | {"{' '}"} 18 | 19 | {"{' '}"} 20 | 21 | {"{' '}"} 22 | 23 | {"{' '}"} 24 | 25 | 26 | ); 27 | export default SvgComponent; 28 | -------------------------------------------------------------------------------- /src/components/monitorsModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { useMonitorStore } from "../stores/monitors"; 3 | import { MonitorComponent } from "./Monitor"; 4 | import { calculateMinResolution } from "../utils/utilities"; 5 | import { type monitorSelectType } from "../types/rendererTypes"; 6 | import { type Monitor } from "../../shared/types/monitor"; 7 | import { IPC_MAIN_EVENTS } from "../../shared/constants"; 8 | import { playlistStore } from "../stores/playlist"; 9 | const { setSelectedMonitor, registerListener } = window.API_RENDERER; 10 | let firstRender = true; 11 | function Monitors() { 12 | const { 13 | activeMonitor, 14 | monitorsList, 15 | setMonitorsList, 16 | setActiveMonitor, 17 | reQueryMonitors 18 | } = useMonitorStore(); 19 | const { clearPlaylist } = playlistStore(); 20 | let initialSelectState: monitorSelectType = 21 | monitorsList.length > 1 ? "clone" : "individual"; 22 | if (activeMonitor.extendAcrossMonitors) { 23 | initialSelectState = "extend"; 24 | } else if (activeMonitor.monitors.length === 1) { 25 | initialSelectState = "individual"; 26 | } 27 | const [selectType, setSelectType] = 28 | useState(initialSelectState); 29 | const [error, setError] = useState<{ state: boolean; message: string }>({ 30 | state: false, 31 | message: "error" 32 | }); 33 | const closeModal = () => { 34 | modalRef.current?.close(); 35 | }; 36 | const [resolution, setResolution] = useState<{ x: number; y: number }>({ 37 | x: 0, 38 | y: 0 39 | }); 40 | console.log(monitorsList); 41 | const onSubmit = () => { 42 | const extend = selectType === "extend"; 43 | let name: string = ""; 44 | const selectedMonitors: Monitor[] = []; 45 | monitorsList.forEach(monitor => { 46 | if (!monitor.isSelected) return; 47 | name = name.concat(monitor.name, ","); 48 | const { isSelected, ...selectedMonitor } = monitor; 49 | selectedMonitors.push(selectedMonitor); 50 | }); 51 | if (selectedMonitors.length === 0) { 52 | setError({ state: true, message: "Select at least one display" }); 53 | setTimeout(() => { 54 | setError(prevError => { 55 | return { ...prevError, state: false }; 56 | }); 57 | }, 3000); 58 | return; 59 | } 60 | if (selectType === "individual" && selectedMonitors.length > 1) { 61 | setError({ 62 | state: true, 63 | message: "Cannot select more than one display in this mode" 64 | }); 65 | setTimeout(() => { 66 | setError(prevError => { 67 | return { ...prevError, state: false }; 68 | }); 69 | }, 3000); 70 | return; 71 | } 72 | if ( 73 | (selectType === "clone" || selectType === "extend") && 74 | selectedMonitors.length < 2 75 | ) { 76 | setError({ state: true, message: "Select at least two displays" }); 77 | setTimeout(() => { 78 | setError(prevError => { 79 | return { ...prevError, state: false }; 80 | }); 81 | }, 3000); 82 | return; 83 | } 84 | name = name.slice(0, name.length - 1); 85 | const activeMonitor = { 86 | name, 87 | monitors: selectedMonitors, 88 | extendAcrossMonitors: extend 89 | }; 90 | setSelectedMonitor(activeMonitor); 91 | setActiveMonitor(activeMonitor); 92 | clearPlaylist(); 93 | closeModal(); 94 | }; 95 | const scale = 96 | 1 / 97 | ((monitorsList.length + 1) * (screen.availWidth / window.innerWidth)); 98 | const modalRef = useRef(null); 99 | const styles: React.CSSProperties = { 100 | width: resolution.x * scale, 101 | height: resolution.y * scale 102 | }; 103 | useEffect(() => { 104 | const res = calculateMinResolution(monitorsList); 105 | setResolution(res); 106 | }, [monitorsList, screen.availWidth]); 107 | useEffect(() => { 108 | if (monitorsList.length < 1) return; 109 | if (selectType === "individual") { 110 | const resetMonitors = monitorsList.map((monitor, index) => { 111 | monitor.isSelected = index === 0; 112 | return monitor; 113 | }); 114 | setMonitorsList(resetMonitors); 115 | } else { 116 | monitorsList[0].isSelected = true; 117 | setMonitorsList([...monitorsList]); 118 | } 119 | }, [selectType]); 120 | 121 | useEffect(() => { 122 | if (!firstRender) return; 123 | firstRender = false; 124 | registerListener({ 125 | channel: IPC_MAIN_EVENTS.displaysChanged, 126 | listener: _ => { 127 | // this setTimeout is added to circumvent an swww limitation on querying recently inserted monitorsList 128 | // which sets currentImage to 00000 instead of the actual cached image 129 | setTimeout(() => { 130 | void reQueryMonitors().then(() => { 131 | // @ts-expect-error daisy-ui 132 | window.monitors.showModal(); 133 | }); 134 | }, 300); 135 | } 136 | }); 137 | }, []); 138 | return ( 139 | 145 |
146 |
147 |

148 | Choose Display 149 |

150 |
151 | 176 |
177 |
178 | {monitorsList.map(monitor => { 179 | return ( 180 |
189 | 195 |
196 | ); 197 | })} 198 |
199 |
200 | 204 | {error.message} 205 | 206 | 207 | 213 |
214 |
215 |
216 |
217 | ); 218 | } 219 | export default Monitors; 220 | -------------------------------------------------------------------------------- /src/custom.css: -------------------------------------------------------------------------------- 1 | .pagination { 2 | justify-content: center; 3 | display: flex; 4 | padding-left: 0; 5 | list-style: none; 6 | margin: 0; 7 | } 8 | .pagination .page-item:last-child { 9 | border-radius: 1rem; 10 | } 11 | .page-item .page-link { 12 | display: inline-flex; 13 | flex-shrink: 0; 14 | cursor: pointer; 15 | user-select: none; 16 | flex-wrap: wrap; 17 | align-items: center; 18 | justify-content: center; 19 | border-color: transparent; 20 | border-color: hsl(var(--b2) / var(--tw-border-opacity)); 21 | text-align: center; 22 | transition-property: color, background-color, border-color, 23 | text-decoration-color, fill, stroke, opacity, box-shadow, transform, 24 | filter, backdrop-filter; 25 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 26 | transition-timing-function: cubic-bezier(0, 0, 0.2, 1); 27 | transition-duration: 200ms; 28 | border-radius: var(--rounded-btn, 0.5rem /* 8px */); 29 | height: 4rem /* 48px */; 30 | width: 4rem; 31 | padding-left: 1rem /* 16px */; 32 | padding-right: 1rem /* 16px */; 33 | font-size: 0.875rem /* 14px */; 34 | line-height: 1.25rem /* 20px */; 35 | line-height: 1em; 36 | min-height: 3rem /* 48px */; 37 | gap: 0.5rem /* 8px */; 38 | font-weight: 600; 39 | text-decoration-line: none; 40 | text-decoration-line: none; 41 | border-width: var(--border-btn, 1px); 42 | 43 | text-transform: var(--btn-text-case, uppercase); 44 | --tw-border-opacity: 1; 45 | --tw-bg-opacity: 1; 46 | background-color: #0f0f0f; 47 | color: hsl(var(--bc) / var(--tw-text-opacity)); 48 | outline-color: hsl(var(--bc) / 1); 49 | border-color: transparent !important; 50 | } 51 | 52 | .page-item a.page-link:hover { 53 | background-color: #000000; 54 | color: #ebdbb2; 55 | border-color: transparent !important; 56 | } 57 | 58 | .page-item.active .page-link { 59 | font-weight: 700; 60 | color: #ffffff; 61 | background-color: #000; 62 | border-color: transparent !important; 63 | } 64 | 65 | .page-item.disabled .page-link { 66 | color: #fff; 67 | background-color: #0f0f0f; 68 | pointer-events: none; 69 | cursor: auto; 70 | } 71 | 72 | .rounded_button_next :first-child { 73 | border-radius: 0 0.5rem 0.5rem 0 !important; 74 | } 75 | 76 | .rounded_button_previous :first-child { 77 | border-radius: 0.5rem 0 0 0.5rem !important; 78 | } 79 | -------------------------------------------------------------------------------- /src/hooks/useDebounce.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, type DependencyList } from "react"; 2 | import useTimeout from "./useTimeout"; 3 | 4 | type Callback = () => void; 5 | 6 | export default function useDebounce( 7 | callback: Callback, 8 | delay: number, 9 | dependencies: DependencyList 10 | ): void { 11 | const { reset, clear } = useTimeout({ callback, delay }); 12 | useEffect(reset, [...dependencies, reset]); 13 | useEffect(clear, []); 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useDebounceCallback.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | type callback = (...args: any[]) => void; 4 | 5 | function useDebounceCallback(callback: callback, delay = 500) { 6 | const timeoutRef = useRef<{ id: null | NodeJS.Timeout }>({ id: null }); 7 | 8 | useEffect(() => { 9 | return () => { 10 | // Clean up the timeout when the component unmounts 11 | if (timeoutRef.current.id !== null) { 12 | clearTimeout(timeoutRef.current.id); 13 | } 14 | }; 15 | }, []); 16 | return function debouncedCallback(...args: any[]) { 17 | // Clear the previous timeout if it exists 18 | if (timeoutRef.current.id !== null) { 19 | clearTimeout(timeoutRef.current.id); 20 | } 21 | 22 | // Set a new timeout with the specified delay 23 | timeoutRef.current.id = setTimeout(() => { 24 | // Call the original callback with the provided arguments 25 | callback(args); 26 | timeoutRef.current.id = null; 27 | }, delay); 28 | }; 29 | } 30 | 31 | export default useDebounceCallback; 32 | -------------------------------------------------------------------------------- /src/hooks/useFilteredImages.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from "react"; 2 | import { imagesStore } from "../stores/images"; 3 | import { type rendererImage } from "../types/rendererTypes"; 4 | import { useHotkeys } from "react-hotkeys-hook"; 5 | export function useFilteredImages() { 6 | // The default order is descending, the images come in sorted from the database by ID in descending order. 7 | // So we must respect that order in the ordering of names 8 | // And we "order" by ascending or descending 9 | const { imagesArray, filters, setSelectedImages } = imagesStore(); 10 | const [filteredImages, setFilteredImages] = 11 | useState(imagesArray); 12 | const selectAllImages = useCallback(() => { 13 | const selectedImages = new Set(); 14 | for (let index = 0; index < filteredImages.length; index++) { 15 | filteredImages[index].isSelected = 16 | !filteredImages[index].isSelected; 17 | if (filteredImages[index].isSelected) { 18 | selectedImages.add(filteredImages[index].id); 19 | } 20 | } 21 | setSelectedImages(selectedImages); 22 | }, [filteredImages]); 23 | const clearSelection = useCallback(() => { 24 | for (let index = 0; index < filteredImages.length; index++) { 25 | filteredImages[index].isSelected = false; 26 | } 27 | setSelectedImages(new Set()); 28 | }, [filteredImages]); 29 | useHotkeys("ctrl+shift+a", selectAllImages); 30 | useHotkeys("escape", clearSelection); 31 | const sortedImages = useMemo(() => { 32 | if (filters.type === "id") return [...imagesArray]; 33 | const shallowCopy = [...imagesArray]; 34 | shallowCopy.sort((a, b) => b.name.localeCompare(a.name)); 35 | return shallowCopy; 36 | }, [imagesArray, filters]); 37 | 38 | useEffect(() => { 39 | // this is done on purpose to prevent as much iterations of sortedImages as possible 40 | const dontFilterByResolution = 41 | filters.advancedFilters.resolution.constraint === "all" || 42 | filters.advancedFilters.resolution.width + 43 | filters.advancedFilters.resolution.height === 44 | 0; 45 | const dontFilterByFormat = 46 | filters.advancedFilters.formats.length === 10; 47 | const dontFilterByName = filters.searchString === ""; 48 | const imagesfilteredByResolution: rendererImage[] = 49 | dontFilterByResolution 50 | ? sortedImages 51 | : sortedImages.filter(image => { 52 | const widthToFilter = 53 | filters.advancedFilters.resolution.width; 54 | const heightToFilter = 55 | filters.advancedFilters.resolution.height; 56 | switch (filters.advancedFilters.resolution.constraint) { 57 | case "exact": 58 | return ( 59 | image.width === widthToFilter && 60 | image.height === heightToFilter 61 | ); 62 | case "lessThan": 63 | return ( 64 | image.width <= widthToFilter && 65 | image.height <= heightToFilter 66 | ); 67 | case "moreThan": 68 | return ( 69 | image.width >= widthToFilter && 70 | image.height >= heightToFilter 71 | ); 72 | } 73 | return undefined; 74 | }); 75 | let imagesFilteredByFormat: rendererImage[]; 76 | if (filters.advancedFilters.formats.length === 0) { 77 | imagesFilteredByFormat = []; 78 | } else { 79 | imagesFilteredByFormat = dontFilterByFormat 80 | ? imagesfilteredByResolution 81 | : imagesfilteredByResolution.filter(images => { 82 | return filters.advancedFilters.formats.includes( 83 | images.format 84 | ); 85 | }); 86 | } 87 | const imagesFilteredByName: rendererImage[] = dontFilterByName 88 | ? imagesFilteredByFormat 89 | : imagesFilteredByFormat.filter(image => { 90 | return image.name 91 | .toLocaleLowerCase() 92 | .includes(filters.searchString.toLocaleLowerCase()); 93 | }); 94 | setFilteredImages(imagesFilteredByName); 95 | }, [sortedImages, filters]); 96 | 97 | return { 98 | filteredImages, 99 | selectAllImages, 100 | clearSelection 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /src/hooks/useImagePagination.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | useEffect, 4 | useCallback, 5 | useMemo, 6 | lazy, 7 | Suspense 8 | } from "react"; 9 | import { useFilteredImages } from "./useFilteredImages"; 10 | import { imagesStore } from "../stores/images"; 11 | import Skeleton from "../components/Skeleton"; 12 | import { useHotkeys } from "react-hotkeys-hook"; 13 | import { type rendererImage } from "../types/rendererTypes"; 14 | import { MENU_EVENTS } from "../../shared/constants"; 15 | import { useAppConfigStore } from "../stores/appConfig"; 16 | import { playlistStore } from "../stores/playlist"; 17 | const ImageCard = lazy(async () => await import("../components/ImageCard")); 18 | const { registerListener } = window.API_RENDERER; 19 | export function useImagePagination() { 20 | const { appConfig } = useAppConfigStore(); 21 | const { removeImagesFromPlaylist, addImagesToPlaylist } = playlistStore(); 22 | const [imagesPerPage, setImagesPerPage] = useState(appConfig.imagesPerPage); 23 | const [currentPage, setCurrentPage] = useState(1); 24 | const { 25 | skeletonsToShow, 26 | filters, 27 | selectedImages, 28 | setSelectedImages, 29 | deleteSelectedImages, 30 | getSelectedImages, 31 | removeImagesFromStore 32 | } = imagesStore(); 33 | const { filteredImages, selectAllImages, clearSelection } = 34 | useFilteredImages(); 35 | const lastImageIndex = useMemo( 36 | () => currentPage * imagesPerPage, 37 | [currentPage, imagesPerPage] 38 | ); 39 | const firstImageIndex = useMemo( 40 | () => lastImageIndex - imagesPerPage, 41 | [lastImageIndex, imagesPerPage] 42 | ); 43 | const totalImages = useMemo(() => { 44 | return filteredImages.length - 1; 45 | }, [filteredImages]); 46 | const lastImageIndexReversed = useMemo( 47 | () => totalImages - (currentPage - 1) * imagesPerPage, 48 | [currentPage, imagesPerPage, totalImages] 49 | ); 50 | const firstImageIndexReversed = useMemo( 51 | () => lastImageIndexReversed - imagesPerPage, 52 | [lastImageIndexReversed, imagesPerPage] 53 | ); 54 | const totalPages = useMemo(() => { 55 | const totalGalleryItems = 56 | filteredImages.length + (skeletonsToShow?.fileNames.length ?? 0); 57 | return Math.ceil(totalGalleryItems / imagesPerPage); 58 | }, [filteredImages, skeletonsToShow, imagesPerPage]); 59 | const SkeletonsArray = useMemo(() => { 60 | if (skeletonsToShow !== undefined) { 61 | return skeletonsToShow.fileNames.map((imageName, index) => { 62 | const imagePath = skeletonsToShow.imagePaths[index]; 63 | return ; 64 | }); 65 | } 66 | return []; 67 | }, [skeletonsToShow]); 68 | const [imagesToShow, imagesInCurrentPage] = useMemo(() => { 69 | const imageCardJsxArray: JSX.Element[] = []; 70 | const imagesInCurrentPage: rendererImage[] = []; 71 | if (filters.order === "desc") { 72 | for (let idx = firstImageIndex; idx < lastImageIndex; idx++) { 73 | const currentImage = filteredImages[idx]; 74 | if (currentImage === undefined) break; 75 | imagesInCurrentPage.push(currentImage); 76 | const imageJsxElement = ( 77 | 78 | 79 | 80 | ); 81 | imageCardJsxArray.push(imageJsxElement); 82 | } 83 | } else { 84 | for ( 85 | let idx = lastImageIndexReversed; 86 | idx > firstImageIndexReversed; 87 | idx-- 88 | ) { 89 | const currentImage = filteredImages[idx]; 90 | if (currentImage === undefined) break; 91 | imagesInCurrentPage.push(currentImage); 92 | const imageJsxElement = ( 93 | 94 | 95 | 96 | ); 97 | imageCardJsxArray.push(imageJsxElement); 98 | } 99 | } 100 | return [[...SkeletonsArray, ...imageCardJsxArray], imagesInCurrentPage]; 101 | }, [filteredImages, filters, currentPage, totalPages]); 102 | const handlePageChange = useCallback((page: number) => { 103 | setCurrentPage(page); 104 | }, []); 105 | const selectImagesInCurrentPage = () => { 106 | const newSet = new Set(selectedImages); 107 | imagesInCurrentPage.forEach(image => { 108 | image.isSelected = !image.isSelected; 109 | if (image.isSelected) { 110 | newSet.add(image.id); 111 | } else { 112 | newSet.delete(image.id); 113 | } 114 | }); 115 | setSelectedImages(newSet); 116 | }; 117 | const clearSelectedImagesInCurrentPage = () => { 118 | const newSet = new Set(selectedImages); 119 | imagesInCurrentPage.forEach(image => { 120 | image.isSelected = false; 121 | newSet.delete(image.id); 122 | }); 123 | setSelectedImages(newSet); 124 | }; 125 | useHotkeys( 126 | "ctrl+a", 127 | () => { 128 | const newSet = new Set(selectedImages); 129 | imagesInCurrentPage.forEach(image => { 130 | image.isSelected = !image.isSelected; 131 | if (image.isSelected) { 132 | newSet.add(image.id); 133 | } else { 134 | newSet.delete(image.id); 135 | } 136 | }); 137 | setSelectedImages(newSet); 138 | }, 139 | [imagesInCurrentPage, selectedImages] 140 | ); 141 | type registerListenerArgs = Parameters[0]; 142 | 143 | const eventsMap: registerListenerArgs[] = [ 144 | { 145 | channel: MENU_EVENTS.clearSelection, 146 | listener: _ => { 147 | clearSelection(); 148 | } 149 | }, 150 | { 151 | channel: MENU_EVENTS.setImagesPerPage, 152 | listener: (_, imagesPerPage: number) => { 153 | setImagesPerPage(imagesPerPage); 154 | } 155 | }, 156 | { 157 | channel: MENU_EVENTS.selectAllImagesInGallery, 158 | listener: _ => { 159 | selectAllImages(); 160 | } 161 | }, 162 | { 163 | channel: MENU_EVENTS.selectAllImagesInCurrentPage, 164 | listener: _ => { 165 | selectImagesInCurrentPage(); 166 | } 167 | }, 168 | { 169 | channel: MENU_EVENTS.clearSelectionOnCurrentPage, 170 | listener: _ => { 171 | clearSelectedImagesInCurrentPage(); 172 | } 173 | }, 174 | { 175 | channel: MENU_EVENTS.removeSelectedImagesFromPlaylist, 176 | listener: _ => { 177 | removeImagesFromPlaylist(selectedImages); 178 | } 179 | }, 180 | { 181 | channel: MENU_EVENTS.deleteAllSelectedImages, 182 | listener: _ => { 183 | deleteSelectedImages(); 184 | } 185 | }, 186 | { 187 | channel: MENU_EVENTS.addSelectedImagesToPlaylist, 188 | listener: _ => { 189 | addImagesToPlaylist(getSelectedImages()); 190 | } 191 | }, 192 | { 193 | channel: MENU_EVENTS.deleteImageFromGallery, 194 | listener: (_, image: rendererImage) => { 195 | removeImagesFromStore([image]); 196 | } 197 | } 198 | ]; 199 | useEffect(() => { 200 | eventsMap.forEach(eventToRegister => { 201 | registerListener(eventToRegister); 202 | }); 203 | }, [eventsMap, selectedImages]); 204 | 205 | useEffect(() => { 206 | if (imagesToShow.length === 0) { 207 | setCurrentPage(totalPages); 208 | } 209 | if (filters.searchString === "") { 210 | setCurrentPage(1); 211 | } 212 | }, [imagesPerPage, totalPages, filters]); 213 | return { 214 | currentPage, 215 | totalPages, 216 | imagesToShow, 217 | handlePageChange, 218 | filteredImages, 219 | imagesInCurrentPage, 220 | selectedImages 221 | }; 222 | } 223 | -------------------------------------------------------------------------------- /src/hooks/useLoadAppConfig.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { useAppConfigStore } from "../stores/appConfig"; 3 | const { readAppConfig } = window.API_RENDERER; 4 | 5 | export function useLoadAppConfig() { 6 | const { saveConfig, isSetup } = useAppConfigStore(); 7 | const loadAppConfig = useCallback(() => { 8 | if (isSetup) return; 9 | void readAppConfig().then(config => { 10 | saveConfig(config); 11 | }); 12 | }, [isSetup]); 13 | return loadAppConfig; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useLoadImages.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { imagesStore } from "../stores/images"; 3 | import { useShallow } from "zustand/react/shallow"; 4 | let firstRender = true; 5 | 6 | export function useLoadImages() { 7 | const reQueryImages = imagesStore(useShallow(state => state.reQueryImages)); 8 | const loadImages = useCallback(() => { 9 | if (!firstRender) return; 10 | firstRender = false; 11 | reQueryImages(); 12 | }, [firstRender]); 13 | return loadImages; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useOnDeleteImage.tsx: -------------------------------------------------------------------------------- 1 | import { imagesStore } from "../stores/images"; 2 | import { playlistStore } from "../stores/playlist"; 3 | import { useEffect } from "react"; 4 | import { useShallow } from "zustand/react/shallow"; 5 | const { onDeleteImageFromGallery } = window.API_RENDERER; 6 | let firstCall = true; 7 | export function registerOnDelete() { 8 | const removeImagesFromStore = imagesStore( 9 | useShallow(state => state.removeImagesFromStore) 10 | ); 11 | const removeImageFromPlaylist = playlistStore( 12 | useShallow(state => state.removeImagesFromPlaylist) 13 | ); 14 | useEffect(() => { 15 | if (!firstCall) return; 16 | firstCall = false; 17 | onDeleteImageFromGallery((_, image) => { 18 | removeImagesFromStore([image]); 19 | removeImageFromPlaylist(new Set([image.id])); 20 | }); 21 | }, []); 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/useOpenImages.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { type openFileAction, type imagesObject } from "../../shared/types"; 3 | import { type rendererImage } from "../types/rendererTypes"; 4 | const { openFiles, handleOpenImages } = window.API_RENDERER; 5 | interface State { 6 | isActive: boolean; 7 | } 8 | interface openImagesProps { 9 | setSkeletons: (skeletons: imagesObject | undefined) => void; 10 | addImages: (imagesArray: rendererImage[]) => void; 11 | addImagesToPlaylist: (Images: rendererImage[]) => void; 12 | action: openFileAction; 13 | } 14 | 15 | interface Actions { 16 | openImages: (openImagesProps: openImagesProps) => Promise; 17 | } 18 | 19 | const openImagesStore = create(set => ({ 20 | isActive: false, 21 | openImages: async ({ 22 | setSkeletons, 23 | addImages, 24 | addImagesToPlaylist, 25 | action 26 | }) => { 27 | set(() => ({ isActive: true })); 28 | const imagesObject: imagesObject | undefined = await openFiles(action); 29 | set(() => ({ isActive: false })); 30 | if (imagesObject === undefined) return; 31 | imagesObject.fileNames.reverse(); 32 | imagesObject.imagePaths.reverse(); 33 | setSkeletons(imagesObject); 34 | const imagesArray = await handleOpenImages(imagesObject); 35 | const newImagesAdded = imagesArray.map(image => { 36 | const shouldCheckImage = true; 37 | return { 38 | ...image, 39 | isChecked: shouldCheckImage, 40 | time: null 41 | }; 42 | }); 43 | setSkeletons(undefined); 44 | addImages(newImagesAdded); 45 | addImagesToPlaylist(newImagesAdded); 46 | } 47 | })); 48 | 49 | export default openImagesStore; 50 | -------------------------------------------------------------------------------- /src/hooks/useSetLastActivePlaylist.tsx: -------------------------------------------------------------------------------- 1 | import { playlistStore } from "../stores/playlist"; 2 | import { useMonitorStore } from "../stores/monitors"; 3 | import { 4 | type rendererPlaylist, 5 | type rendererImage 6 | } from "../types/rendererTypes"; 7 | import { imagesStore } from "../stores/images"; 8 | import { PLAYLIST_TYPES } from "../../shared/types/playlist"; 9 | import { useEffect } from "react"; 10 | const { readActivePlaylist, deletePlaylist } = window.API_RENDERER; 11 | export function useSetLastActivePlaylist() { 12 | const { setPlaylist, playlist } = playlistStore(); 13 | const { activeMonitor } = useMonitorStore(); 14 | const { imagesArray } = imagesStore(); 15 | useEffect(() => { 16 | if (activeMonitor.name === "") return; 17 | void readActivePlaylist(activeMonitor).then(playlistFromDB => { 18 | if (playlistFromDB === undefined) { 19 | // setEmptyPlaylist(); 20 | return; 21 | } 22 | if (playlistFromDB.images.length < 1) { 23 | deletePlaylist(playlistFromDB.name); 24 | return; 25 | } 26 | 27 | if (playlist.name === playlistFromDB.name) { 28 | return; 29 | } 30 | const imagesToStorePlaylist: rendererImage[] = []; 31 | playlistFromDB.images.forEach(imageInActivePlaylist => { 32 | const imageToCheck = imagesArray.find(imageInGallery => { 33 | return imageInGallery.name === imageInActivePlaylist.name; 34 | }); 35 | if (imageToCheck === undefined) { 36 | return; 37 | } 38 | if ( 39 | playlistFromDB.type === PLAYLIST_TYPES.TIME_OF_DAY && 40 | imageInActivePlaylist.time !== null 41 | ) { 42 | imageToCheck.time = imageInActivePlaylist.time; 43 | } 44 | imageToCheck.isChecked = true; 45 | imagesToStorePlaylist.push(imageToCheck); 46 | }); 47 | const currentPlaylist: rendererPlaylist = { 48 | name: playlistFromDB.name, 49 | configuration: { 50 | type: playlistFromDB.type, 51 | order: playlistFromDB.order, 52 | interval: playlistFromDB.interval, 53 | showAnimations: playlistFromDB.showAnimations, 54 | alwaysStartOnFirstImage: 55 | playlistFromDB.alwaysStartOnFirstImage 56 | }, 57 | images: imagesToStorePlaylist, 58 | activeMonitor 59 | }; 60 | setPlaylist(currentPlaylist); 61 | }); 62 | }, [activeMonitor]); 63 | } 64 | -------------------------------------------------------------------------------- /src/hooks/useThrottle.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | 3 | function useThrottle(callback: (...args: any) => void, limit = 1000) { 4 | const lastRun = useRef(Date.now()); 5 | return () => { 6 | if (Date.now() - lastRun.current >= limit) { 7 | callback(); 8 | lastRun.current = Date.now(); 9 | } 10 | }; 11 | } 12 | 13 | export default useThrottle; 14 | -------------------------------------------------------------------------------- /src/hooks/useTimeout.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react"; 2 | 3 | interface TimeoutOptions { 4 | callback: () => void; 5 | delay: number; 6 | } 7 | 8 | export default function useTimeout({ callback, delay }: TimeoutOptions) { 9 | const callbackRef = useRef<() => void>(callback); 10 | const timeoutRef = useRef(); 11 | 12 | useEffect(() => { 13 | callbackRef.current = callback; 14 | }, [callback]); 15 | 16 | const set = useCallback(() => { 17 | timeoutRef.current = setTimeout(() => { 18 | callbackRef.current(); 19 | }, delay); 20 | }, [delay]); 21 | 22 | const clear = useCallback(() => { 23 | if (timeoutRef.current !== undefined) { 24 | clearTimeout(timeoutRef.current); 25 | } 26 | }, []); 27 | 28 | useEffect(() => { 29 | set(); 30 | return clear; 31 | }, [delay, set, clear]); 32 | 33 | const reset = useCallback(() => { 34 | clear(); 35 | set(); 36 | }, [clear, set]); 37 | 38 | return { reset, clear }; 39 | } 40 | -------------------------------------------------------------------------------- /src/hooks/useWindowSize.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import useThrottle from "./useThrottle"; 3 | function useWindowSize() { 4 | const [dimensions, setDimensions] = useState({ 5 | width: window.innerWidth, 6 | height: window.innerHeight 7 | }); 8 | 9 | const setDimensionsThrottle = useThrottle(() => { 10 | const newDimensions = { 11 | width: window.innerWidth, 12 | height: window.innerHeight 13 | }; 14 | setDimensions(newDimensions); 15 | }); 16 | useEffect(() => { 17 | window.addEventListener("resize", setDimensionsThrottle); 18 | window.removeEventListener("resize", setDimensionsThrottle); 19 | }, [dimensions]); 20 | return dimensions; 21 | } 22 | 23 | export default useWindowSize; 24 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | const root = document.getElementById("root"); 6 | if (root === null) { 7 | throw new Error("Could not find root div element"); 8 | } 9 | createRoot(root).render( 10 | 11 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /src/routes/AppConfiguration.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import { useEffect } from "react"; 3 | import { AnimatePresence, motion } from "framer-motion"; 4 | import { 5 | type appConfigSelectType, 6 | type appConfigInsertType 7 | } from "../../database/schema"; 8 | 9 | const { readAppConfig, updateAppConfig } = window.API_RENDERER; 10 | const AppConfiguration = () => { 11 | const { register, handleSubmit, setValue } = 12 | useForm(); 13 | const onSubmit = (data: appConfigSelectType["config"]) => { 14 | updateAppConfig(data); 15 | }; 16 | useEffect(() => { 17 | void readAppConfig().then((data: appConfigSelectType["config"]) => { 18 | setValue("killDaemon", data.killDaemon); 19 | setValue("notifications", data.notifications); 20 | setValue("startMinimized", data.startMinimized); 21 | setValue("minimizeInsteadOfClose", data.minimizeInsteadOfClose); 22 | setValue("showMonitorModalOnStart", data.showMonitorModalOnStart); 23 | setValue("imagesPerPage", data.imagesPerPage); 24 | setValue("randomImageMonitor", data.randomImageMonitor); 25 | }); 26 | }, []); 27 | 28 | return ( 29 | <> 30 | 31 | 38 |

39 | App Settings 40 |

41 |
42 |
{ 45 | void handleSubmit(onSubmit)(e); 46 | }} 47 | > 48 |
49 |
50 | 55 | 61 |
62 |
63 | 71 | 77 |
78 |
79 | 87 | 93 |
94 |
95 | 103 | 109 |
110 |
111 | 119 | 125 |
126 |
127 | 135 | 147 |
{" "} 148 |
149 | 157 | 168 |
169 |
170 |
171 |
172 | 178 |
179 |
180 |
181 |
182 | 183 | ); 184 | }; 185 | export default AppConfiguration; 186 | -------------------------------------------------------------------------------- /src/routes/Home.tsx: -------------------------------------------------------------------------------- 1 | import Gallery from "../components/Gallery"; 2 | import { useAppConfigStore } from "../stores/appConfig"; 3 | import Modals from "../components/Modals"; 4 | import { useEffect } from "react"; 5 | import { IPC_MAIN_EVENTS } from "../../shared/constants"; 6 | let firstRender = true; 7 | const { registerListener } = window.API_RENDERER; 8 | const Home = () => { 9 | const { isSetup, requeryAppConfig } = useAppConfigStore(); 10 | useEffect(() => { 11 | if (!firstRender) return; 12 | firstRender = false; 13 | registerListener({ 14 | channel: IPC_MAIN_EVENTS.updateAppConfig, 15 | listener: _ => { 16 | void requeryAppConfig(); 17 | } 18 | }); 19 | }, []); 20 | if (!isSetup) return null; 21 | return ( 22 | <> 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default Home; 30 | -------------------------------------------------------------------------------- /src/stores/appConfig.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { type appConfigType } from "../../shared/types/app"; 3 | import { initialAppConfig } from "../../shared/constants"; 4 | const { updateAppConfig, readAppConfig } = window.API_RENDERER; 5 | interface State { 6 | appConfig: appConfigType; 7 | isSetup: boolean; 8 | } 9 | 10 | interface Actions { 11 | saveConfig: (data: appConfigType) => void; 12 | requeryAppConfig: () => Promise; 13 | } 14 | 15 | export const useAppConfigStore = create()(set => ({ 16 | appConfig: initialAppConfig, 17 | isSetup: false, 18 | saveConfig: newConfig => { 19 | updateAppConfig(newConfig); 20 | set(() => ({ appConfig: newConfig, isSetup: true })); 21 | }, 22 | requeryAppConfig: async () => { 23 | const newConfig = await readAppConfig(); 24 | set(() => ({ appConfig: newConfig })); 25 | } 26 | })); 27 | -------------------------------------------------------------------------------- /src/stores/images.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { type Filters, type rendererImage } from "../types/rendererTypes"; 3 | import { type imagesObject } from "../../shared/types"; 4 | import { playlistStore } from "./playlist"; 5 | const { queryImages, deleteImagesFromGallery } = window.API_RENDERER; 6 | const initialFilters: Filters = { 7 | order: "desc", 8 | type: "id", 9 | searchString: "", 10 | advancedFilters: { 11 | formats: [ 12 | "jpeg", 13 | "jpg", 14 | "webp", 15 | "gif", 16 | "png", 17 | "bmp", 18 | "tiff", 19 | "tga", 20 | "pnm", 21 | "farbfeld" 22 | ], 23 | resolution: { 24 | constraint: "all", 25 | width: 0, 26 | height: 0 27 | } 28 | } 29 | }; 30 | interface State { 31 | imagesArray: rendererImage[]; 32 | imagesMap: Map; 33 | skeletonsToShow: imagesObject | undefined; 34 | filteredImages: rendererImage[]; 35 | isEmpty: boolean; 36 | isQueried: boolean; 37 | filters: Filters; 38 | selectedImages: Set; 39 | addImages: (newImages: rendererImage[]) => void; 40 | setFilters: (newFilters: Filters) => void; 41 | getFilters: () => Filters; 42 | setSkeletons: (skeletons: imagesObject | undefined) => void; 43 | setFilteredImages: (filteredImages: rendererImage[]) => void; 44 | setSelectedImages: (newSelectedImages: Set) => void; 45 | clearSkeletons: () => void; 46 | removeImagesFromStore: (images: rendererImage[]) => void; 47 | reQueryImages: () => void; 48 | addToSelectedImages: (imageSelected: rendererImage) => void; 49 | removeFromSelectedImages: (imageSelected: rendererImage) => void; 50 | deleteSelectedImages: () => void; 51 | getSelectedImages: () => rendererImage[]; 52 | } 53 | 54 | export const imagesStore = create()((set, get) => ({ 55 | imagesArray: [] as rendererImage[], 56 | imagesMap: new Map(), 57 | skeletonsToShow: undefined, 58 | filteredImages: [] as rendererImage[], 59 | isEmpty: true, 60 | isQueried: false, 61 | filters: initialFilters, 62 | selectedImages: new Set(), 63 | setFilters: newFilters => { 64 | set(() => ({ filters: newFilters })); 65 | }, 66 | setFilteredImages: filteredImages => { 67 | set(() => ({ filteredImages })); 68 | }, 69 | setSelectedImages: selectedImages => { 70 | set(() => ({ selectedImages })); 71 | }, 72 | getSelectedImages: () => { 73 | const selectedImages: rendererImage[] = []; 74 | const imagesMap = get().imagesMap; 75 | const selectedImagesSet = get().selectedImages; 76 | selectedImagesSet.forEach(id => { 77 | const currentImage = imagesMap.get(id); 78 | if (currentImage !== undefined) { 79 | selectedImages.push(currentImage); 80 | } 81 | }); 82 | return selectedImages; 83 | }, 84 | addImages: newImages => { 85 | const filters = get().filters; 86 | let newImagesArray: rendererImage[] = []; 87 | if (filters.order === "desc") { 88 | newImagesArray = [...newImages, ...get().imagesArray]; 89 | } else { 90 | newImagesArray = [...get().imagesArray, ...newImages]; 91 | } 92 | const oldImagesMap = get().imagesMap; 93 | newImages.forEach(image => { 94 | oldImagesMap.set(image.id, image); 95 | }); 96 | set(() => ({ 97 | imagesArray: newImagesArray, 98 | imagesMap: new Map(oldImagesMap) 99 | })); 100 | }, 101 | setSkeletons: skeletons => { 102 | set(() => ({ skeletonsToShow: skeletons, isEmpty: false })); 103 | }, 104 | clearSkeletons: () => { 105 | set(() => ({ skeletonsToShow: undefined })); 106 | }, 107 | removeImagesFromStore: images => { 108 | set(state => { 109 | const imagesMap = get().imagesMap; 110 | const selectedImages = get().selectedImages; 111 | const imagesSetToDelete = new Set(); 112 | images.forEach(imageToDelete => { 113 | imagesMap.delete(imageToDelete.id); 114 | selectedImages.delete(imageToDelete.id); 115 | imagesSetToDelete.add(imageToDelete.id); 116 | }); 117 | playlistStore 118 | .getState() 119 | .removeImagesFromPlaylist(imagesSetToDelete); 120 | return { 121 | ...state, 122 | imagesArray: Array.from(imagesMap.values()), 123 | imagesMap: new Map(imagesMap) 124 | }; 125 | }); 126 | }, 127 | reQueryImages: () => { 128 | void queryImages().then(images => { 129 | const isEmpty = images.length <= 0; 130 | const newImagesMap = new Map(); 131 | images.forEach(image => { 132 | newImagesMap.set(image.id, image); 133 | }); 134 | set(() => ({ 135 | imagesArray: images, 136 | isEmpty, 137 | isQueried: true, 138 | imagesMap: newImagesMap 139 | })); 140 | }); 141 | }, 142 | addToSelectedImages(imageSelected) { 143 | get().selectedImages.add(imageSelected.id); 144 | set(state => ({ selectedImages: new Set(state.selectedImages) })); 145 | }, 146 | removeFromSelectedImages(imageSelected) { 147 | get().selectedImages.delete(imageSelected.id); 148 | set(state => ({ selectedImages: new Set(state.selectedImages) })); 149 | }, 150 | deleteSelectedImages() { 151 | const imagesToDelete: rendererImage[] = []; 152 | const imagesSetToDelete = new Set(); 153 | const newImagesMap = new Map(get().imagesMap); 154 | const newSelectedImages = new Set(get().selectedImages); 155 | newSelectedImages.forEach(id => { 156 | const image = newImagesMap.get(id); 157 | if (image === undefined) return; 158 | newImagesMap.delete(id); 159 | newSelectedImages.delete(id); 160 | imagesToDelete.push(image); 161 | imagesSetToDelete.add(id); 162 | }); 163 | void deleteImagesFromGallery(imagesToDelete).then(() => { 164 | set(() => ({ 165 | imagesMap: newImagesMap, 166 | imagesArray: Array.from(newImagesMap.values()), 167 | selectedImages: newSelectedImages 168 | })); 169 | playlistStore 170 | .getState() 171 | .removeImagesFromPlaylist(imagesSetToDelete); 172 | }); 173 | }, 174 | getFilters() { 175 | return get().filters; 176 | } 177 | })); 178 | -------------------------------------------------------------------------------- /src/stores/monitors.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { type Monitor, type ActiveMonitor } from "../../shared/types/monitor"; 3 | import { verifyOldMonitorConfigValidity } from "../utils/utilities"; 4 | const { getMonitors, querySelectedMonitor } = window.API_RENDERER; 5 | 6 | export interface StoreMonitor extends Monitor { 7 | isSelected: boolean; 8 | } 9 | 10 | interface MonitorStore { 11 | activeMonitor: ActiveMonitor; 12 | monitorsList: StoreMonitor[]; 13 | setActiveMonitor: (value: ActiveMonitor) => void; 14 | setMonitorsList: (monitorsList: StoreMonitor[]) => void; 15 | reQueryMonitors: () => Promise; 16 | setLastSavedMonitorConfig: () => Promise; 17 | } 18 | 19 | const initialState = { 20 | activeMonitor: { 21 | name: "", 22 | monitors: [] as Monitor[], 23 | extendAcrossMonitors: false 24 | }, 25 | monitorsList: [] as StoreMonitor[] 26 | }; 27 | 28 | export const useMonitorStore = create()((set, get) => ({ 29 | activeMonitor: initialState.activeMonitor, 30 | monitorsList: initialState.monitorsList, 31 | setActiveMonitor(value) { 32 | set(state => { 33 | return { 34 | ...state, 35 | activeMonitor: value 36 | }; 37 | }); 38 | }, 39 | setMonitorsList(monitorsList) { 40 | set(state => { 41 | return { 42 | ...state, 43 | monitorsList 44 | }; 45 | }); 46 | }, 47 | async reQueryMonitors() { 48 | const monitors = await getMonitors(); 49 | const activeMonitor = get().activeMonitor; 50 | const storeMonitors = monitors.map(monitor => { 51 | const match = activeMonitor.monitors.find(activeMonitor => { 52 | return activeMonitor.name === monitor.name; 53 | }); 54 | const isSelected = match !== undefined; 55 | return { 56 | ...monitor, 57 | isSelected 58 | }; 59 | }); 60 | set(state => { 61 | return { 62 | ...state, 63 | monitorsList: storeMonitors 64 | }; 65 | }); 66 | }, 67 | async setLastSavedMonitorConfig() { 68 | const oldConfig = await querySelectedMonitor(); 69 | const monitorsList = await getMonitors(); 70 | if ( 71 | oldConfig !== undefined && 72 | verifyOldMonitorConfigValidity({ 73 | oldConfig, 74 | monitorsList 75 | }) 76 | ) { 77 | get().setActiveMonitor(oldConfig); 78 | const monitorList: StoreMonitor[] = oldConfig.monitors.map( 79 | monitor => { 80 | return { 81 | ...monitor, 82 | isSelected: true 83 | }; 84 | } 85 | ); 86 | get().setMonitorsList(monitorList); 87 | } 88 | } 89 | })); 90 | -------------------------------------------------------------------------------- /src/stores/playlist.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { 3 | type rendererImage, 4 | type rendererPlaylist, 5 | type configuration 6 | } from "../types/rendererTypes"; 7 | import { type ActiveMonitor, type Monitor } from "../../shared/types/monitor"; 8 | import { useMonitorStore } from "./monitors"; 9 | const imagesInitial: rendererImage[] = []; 10 | const configurationInitial: rendererPlaylist["configuration"] = { 11 | type: "timer", 12 | interval: 3_600_000, 13 | order: "ordered", 14 | showAnimations: true, 15 | alwaysStartOnFirstImage: false 16 | }; 17 | const initialPlaylistState: rendererPlaylist = { 18 | images: imagesInitial, 19 | configuration: configurationInitial, 20 | name: "", 21 | activeMonitor: { 22 | name: "", 23 | extendAcrossMonitors: false, 24 | monitors: [] as Monitor[] 25 | } 26 | }; 27 | interface State { 28 | playlist: rendererPlaylist; 29 | isEmpty: boolean; 30 | playlistImagesSet: Set; 31 | playlistImagesTimeSet: Set; 32 | lastAddedImageID: number; 33 | } 34 | 35 | interface Actions { 36 | addImagesToPlaylist: (Images: rendererImage[]) => void; 37 | setConfiguration: (newConfiguration: configuration) => void; 38 | setName: (newName: string) => void; 39 | movePlaylistArrayOrder: (newlyOrderedArray: rendererImage[]) => void; 40 | removeImagesFromPlaylist: (Images: Set) => void; 41 | clearPlaylist: (playlistToDelete?: { 42 | name: string; 43 | activeMonitor: ActiveMonitor; 44 | }) => void; 45 | readPlaylist: () => rendererPlaylist; 46 | setPlaylist: (newPlaylist: rendererPlaylist) => void; 47 | setEmptyPlaylist: () => void; 48 | setActiveMonitorPlaylist: (activeMonitor: ActiveMonitor) => void; 49 | } 50 | 51 | export const playlistStore = create()((set, get) => ({ 52 | playlist: initialPlaylistState, 53 | isEmpty: true, 54 | playlistImagesSet: new Set(), 55 | playlistImagesTimeSet: new Set(), 56 | lastAddedImageID: -1, 57 | addImagesToPlaylist: Images => { 58 | const playlistImagesSet = get().playlistImagesSet; 59 | const playlistImagesTimeSet = get().playlistImagesTimeSet; 60 | const currentPlaylist = get().playlist; 61 | if (currentPlaylist.configuration.type === "dayofweek") { 62 | const availableSpace = 7 - currentPlaylist.images.length; 63 | if (availableSpace <= 0) return; 64 | else { 65 | Images = Images.slice(0, availableSpace); 66 | } 67 | } 68 | const imagesToAdd: rendererImage[] = []; 69 | const highestTimeStamp = Math.max(...playlistImagesTimeSet); 70 | const date = new Date(); 71 | let initialTimeStamp = Math.max( 72 | highestTimeStamp, 73 | date.getHours() * 60 + date.getMinutes() 74 | ); 75 | for (let current = 0; current < Images.length; current++) { 76 | if (playlistImagesSet.has(Images[current].id)) { 77 | continue; 78 | } 79 | initialTimeStamp += 5; 80 | if (initialTimeStamp >= 1440) { 81 | initialTimeStamp -= 1439; 82 | } 83 | while (playlistImagesTimeSet.has(initialTimeStamp)) { 84 | initialTimeStamp++; 85 | } 86 | Images[current].time = initialTimeStamp; 87 | Images[current].isChecked = true; 88 | playlistImagesSet.add(Images[current].id); 89 | playlistImagesTimeSet.add(initialTimeStamp); 90 | imagesToAdd.push(Images[current]); 91 | } 92 | set(state => { 93 | const newImages = [...state.playlist.images, ...imagesToAdd]; 94 | const newPlaylist = { 95 | ...state.playlist, 96 | images: newImages 97 | }; 98 | return { 99 | playlist: newPlaylist, 100 | isEmpty: false, 101 | playlistImagesSet: new Set(playlistImagesSet), 102 | playlistImagesTimeSet: new Set(playlistImagesTimeSet), 103 | lastAddedImageID: newPlaylist.images.at(-1)?.id 104 | }; 105 | }); 106 | }, 107 | setConfiguration: newConfiguration => { 108 | set(state => { 109 | return { 110 | playlist: { ...state.playlist, configuration: newConfiguration } 111 | }; 112 | }); 113 | }, 114 | setName: newName => { 115 | set(state => { 116 | return { playlist: { ...state.playlist, name: newName } }; 117 | }); 118 | }, 119 | setActiveMonitorPlaylist: activeMonitor => { 120 | set(state => ({ 121 | playlist: { ...state.playlist, activeMonitor } 122 | })); 123 | }, 124 | movePlaylistArrayOrder: newlyOrderedArray => { 125 | set(state => ({ 126 | playlist: { ...state.playlist, images: newlyOrderedArray } 127 | })); 128 | }, 129 | removeImagesFromPlaylist: Images => { 130 | set(state => { 131 | const newImagesArray = state.playlist.images.filter(image => { 132 | const shouldNotFilter = !Images.has(image.id); 133 | if (Images.has(image.id) && image.time !== null) { 134 | state.playlistImagesTimeSet.delete(image.time); 135 | } 136 | return shouldNotFilter; 137 | }); 138 | Images.forEach(id => { 139 | state.playlistImagesSet.delete(id); 140 | }); 141 | return { 142 | playlist: { 143 | ...state.playlist, 144 | images: newImagesArray 145 | }, 146 | playlistImagesSet: new Set(state.playlistImagesSet), 147 | playlistImagesTimeSet: new Set(state.playlistImagesTimeSet) 148 | }; 149 | }); 150 | }, 151 | clearPlaylist: playlistToDelete => { 152 | const activeMonitor = useMonitorStore.getState().activeMonitor; 153 | const currentPlaylist = get().playlist; 154 | if ( 155 | playlistToDelete === undefined || 156 | (currentPlaylist.name === playlistToDelete.name && 157 | currentPlaylist.activeMonitor.name === 158 | playlistToDelete.activeMonitor.name) 159 | ) { 160 | set(() => { 161 | const emptyPlaylist = { 162 | ...initialPlaylistState, 163 | activeMonitor 164 | }; 165 | return { 166 | playlist: emptyPlaylist, 167 | isEmpty: true, 168 | playlistImagesSet: new Set(), 169 | playlistImagesTimeSet: new Set(), 170 | lastAddedImageID: -1 171 | }; 172 | }); 173 | } 174 | }, 175 | readPlaylist: () => { 176 | return get().playlist; 177 | }, 178 | 179 | setPlaylist: (newPlaylist: rendererPlaylist) => { 180 | const newPlaylistImagesSet = new Set(); 181 | const newPlaylistImagesTimeSet = new Set(); 182 | const date = new Date(); 183 | let initialTimeStamp = date.getHours() * 60 + date.getMinutes(); 184 | newPlaylist.images.forEach(image => { 185 | newPlaylistImagesSet.add(image.id); 186 | if (image.time === null || image.time === undefined) { 187 | initialTimeStamp += 5; 188 | if (initialTimeStamp >= 1440) { 189 | initialTimeStamp -= 1439; 190 | } 191 | while (newPlaylistImagesTimeSet.has(initialTimeStamp)) { 192 | initialTimeStamp++; 193 | } 194 | image.time = initialTimeStamp; 195 | } 196 | newPlaylistImagesTimeSet.add(image.time); 197 | }); 198 | set(() => ({ 199 | playlist: newPlaylist, 200 | isEmpty: false, 201 | playlistImagesSet: newPlaylistImagesSet, 202 | playlistImagesTimeSet: newPlaylistImagesTimeSet 203 | })); 204 | }, 205 | setEmptyPlaylist: () => { 206 | set(() => ({ 207 | playlist: initialPlaylistState, 208 | isEmpty: true, 209 | playlistImagesSet: new Set(), 210 | playlistImagesTimeSet: new Set(), 211 | lastAddedImageID: -1 212 | })); 213 | } 214 | })); 215 | -------------------------------------------------------------------------------- /src/stores/swwwConfig.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { 3 | FilterType, 4 | ResizeType, 5 | TransitionType, 6 | transitionPosition 7 | } from "../../shared/types/swww"; 8 | import { 9 | type swwwConfigSelectType, 10 | type swwwConfigInsertType 11 | } from "../../database/schema"; 12 | 13 | const initialSwwwConfig: swwwConfigInsertType["config"] = { 14 | resizeType: ResizeType.crop, 15 | fillColor: "#000000", 16 | filterType: FilterType.Lanczos3, 17 | transitionType: TransitionType.simple, 18 | transitionStep: 90, 19 | transitionDuration: 3, 20 | transitionFPS: 60, 21 | transitionAngle: 45, 22 | transitionPositionType: "alias", 23 | transitionPosition: transitionPosition.center, 24 | transitionPositionIntX: 960, 25 | transitionPositionIntY: 540, 26 | transitionPositionFloatX: 0.5, 27 | transitionPositionFloatY: 0.5, 28 | invertY: false, 29 | transitionBezier: ".25,.1,.25,1", 30 | transitionWaveX: 20, 31 | transitionWaveY: 20 32 | }; 33 | 34 | interface State { 35 | swwwConfig: swwwConfigInsertType["config"]; 36 | } 37 | 38 | interface Actions { 39 | saveConfig: (data: swwwConfigInsertType["config"]) => void; 40 | getConfig: () => swwwConfigSelectType["config"]; 41 | } 42 | export const swwwConfigStore = create()((set, get) => ({ 43 | swwwConfig: initialSwwwConfig, 44 | saveConfig: (data: swwwConfigInsertType["config"]) => { 45 | set(state => { 46 | return { 47 | ...state, 48 | swwwConfig: data 49 | }; 50 | }); 51 | const { updateSwwwConfig } = window.API_RENDERER; 52 | const newState = get().swwwConfig; 53 | updateSwwwConfig(newState); 54 | }, 55 | getConfig: () => { 56 | return get().swwwConfig; 57 | } 58 | })); 59 | -------------------------------------------------------------------------------- /src/types/rendererTypes.ts: -------------------------------------------------------------------------------- 1 | import { type imagesObject } from "../../shared/types"; 2 | import { type Formats } from "../../shared/types/image"; 3 | import { 4 | type PLAYLIST_TYPES_TYPE, 5 | type PLAYLIST_ORDER_TYPES 6 | } from "../../shared/types/playlist"; 7 | import { type imageSelectType } from "../../database/schema"; 8 | import { type ActiveMonitor } from "../../shared/types/monitor"; 9 | 10 | export enum STORE_ACTIONS { 11 | SET_IMAGES_ARRAY = "SET_IMAGES_ARRAY", 12 | SET_SKELETONS_TO_SHOW = "SET_SKELETONS_TO_SHOW", 13 | SET_FILTERS = "SET_FILTERS", 14 | RESET_IMAGES_ARRAY = "RESET_IMAGES_ARRAY" 15 | } 16 | 17 | export interface configuration { 18 | type: PLAYLIST_TYPES_TYPE; 19 | interval: number | null; 20 | order: PLAYLIST_ORDER_TYPES | null; 21 | showAnimations: boolean; 22 | alwaysStartOnFirstImage: boolean; 23 | } 24 | 25 | export interface rendererImage extends imageSelectType { 26 | time: number | null; 27 | } 28 | export interface rendererPlaylist { 29 | images: rendererImage[]; 30 | configuration: configuration; 31 | name: string; 32 | activeMonitor: ActiveMonitor; 33 | } 34 | export type monitorSelectType = "individual" | "clone" | "extend"; 35 | export interface Filters { 36 | order: "asc" | "desc"; 37 | type: "name" | "id"; 38 | searchString: string; 39 | advancedFilters: advancedFilters; 40 | } 41 | 42 | export interface advancedFilters { 43 | formats: Formats[]; 44 | resolution: { 45 | constraint: resolutionConstraints; 46 | width: number; 47 | height: number; 48 | }; 49 | } 50 | 51 | export type resolutionConstraints = "all" | "exact" | "moreThan" | "lessThan"; 52 | export interface state { 53 | imagesArray: rendererImage[]; 54 | skeletonsToShow: imagesObject | undefined; 55 | filters: Filters; 56 | } 57 | 58 | export type action = 59 | | { type: STORE_ACTIONS.SET_IMAGES_ARRAY; payload: rendererImage[] } 60 | | { 61 | type: STORE_ACTIONS.SET_SKELETONS_TO_SHOW; 62 | payload: imagesObject | undefined; 63 | } 64 | | { type: STORE_ACTIONS.SET_FILTERS; payload: Filters } 65 | | { type: STORE_ACTIONS.RESET_IMAGES_ARRAY; payload: rendererImage[] }; 66 | -------------------------------------------------------------------------------- /src/utils/utilities.ts: -------------------------------------------------------------------------------- 1 | import { type ActiveMonitor, type Monitor } from "../../shared/types/monitor"; 2 | 3 | export function toMS(hours: number, minutes: number) { 4 | return hours * 60 * 60 * 1000 + minutes * 60 * 1000; 5 | } 6 | 7 | export function toHoursAndMinutes(ms: number) { 8 | const hours = Math.floor(ms / (60 * 60 * 1000)); 9 | const minutes = Math.floor((ms - hours * 60 * 60 * 1000) / (60 * 1000)); 10 | return { hours, minutes }; 11 | } 12 | 13 | export function debounce(callback: () => void, timer = 1000) { 14 | let previous: ReturnType | undefined; 15 | return () => { 16 | if (previous !== undefined) { 17 | clearTimeout(previous); 18 | } 19 | previous = setTimeout(() => { 20 | callback(); 21 | }, timer); 22 | }; 23 | } 24 | 25 | export function parseResolution(resolution: string) { 26 | const [width, height] = resolution.split("x"); 27 | return { width: parseInt(width), height: parseInt(height) }; 28 | } 29 | 30 | export function calculateMinResolution(monitors: Monitor[]) { 31 | let maxWidth = 0; 32 | let maxHeight = 0; 33 | 34 | for (const monitor of monitors) { 35 | const effectiveWidth = monitor.width + monitor.position.x; 36 | const effectiveHeight = monitor.height + monitor.position.y; 37 | 38 | if (effectiveWidth > maxWidth) { 39 | maxWidth = effectiveWidth; 40 | } 41 | 42 | if (effectiveHeight > maxHeight) { 43 | maxHeight = effectiveHeight; 44 | } 45 | } 46 | 47 | return { x: maxWidth, y: maxHeight }; 48 | } 49 | 50 | export const monitorsListTest = [ 51 | { 52 | name: "eDP-1", 53 | width: 3840, 54 | height: 2160, 55 | currentImage: "/home/obsy/.waypaper_engine/images/wall2.png", 56 | position: { 57 | x: 0, 58 | y: 0 59 | } 60 | }, 61 | { 62 | name: "HDMI-A-1", 63 | width: 3840, 64 | height: 2160, 65 | currentImage: "/home/obsy/.waypaper_engine/images/wall2.png", 66 | position: { 67 | x: 3840, 68 | y: 0 69 | } 70 | }, 71 | { 72 | name: "HDMI-A-12", 73 | width: 3840, 74 | height: 2160, 75 | currentImage: "/home/obsy/.waypaper_engine/images/wall2.png", 76 | position: { 77 | x: 3840, 78 | y: 2160 79 | } 80 | }, 81 | { 82 | name: "HDMI-b-12", 83 | width: 2160, 84 | height: 3840, 85 | currentImage: "/home/obsy/.waypaper_engine/images/wall2.png", 86 | position: { 87 | x: 7680, 88 | y: 0 89 | } 90 | } 91 | ]; 92 | 93 | export const monitors1080p = [ 94 | { 95 | name: "eDP-1", 96 | width: 1920, 97 | height: 1080, 98 | currentImage: "/home/obsy/.waypaper_engine/images/wall2.png", 99 | position: { 100 | x: 0, 101 | y: 0 102 | } 103 | }, 104 | { 105 | name: "HDMI-A-1", 106 | width: 1920, 107 | height: 1080, 108 | currentImage: "/home/obsy/.waypaper_engine/images/wall2.png", 109 | position: { 110 | x: 1920, 111 | y: 0 112 | } 113 | }, 114 | { 115 | name: "HDMI-A-12", 116 | width: 1920, 117 | height: 1080, 118 | currentImage: "/home/obsy/.waypaper_engine/images/wall2.png", 119 | position: { 120 | x: 1920, 121 | y: 1080 122 | } 123 | }, 124 | { 125 | name: "HDMI-b-12", 126 | width: 1080, 127 | height: 1920, 128 | currentImage: "/home/obsy/.waypaper_engine/images/wall2.png", 129 | position: { 130 | x: 3840, 131 | y: 0 132 | } 133 | } 134 | ]; 135 | 136 | export function verifyOldMonitorConfigValidity({ 137 | oldConfig, 138 | monitorsList 139 | }: { 140 | oldConfig: ActiveMonitor; 141 | monitorsList: Monitor[]; 142 | }): boolean { 143 | let isValid = true; 144 | for (let idx = 0; idx < oldConfig.monitors.length; idx++) { 145 | const oldMonitorName = oldConfig.monitors[idx].name; 146 | const foundMonitor = monitorsList.find( 147 | ({ name }) => name === oldMonitorName 148 | ); 149 | if (foundMonitor === undefined) { 150 | isValid = false; 151 | break; 152 | } 153 | } 154 | return isValid; 155 | } 156 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | plugins: [ 5 | require("tailwind-scrollbar")({ nocompatible: true }), 6 | require("daisyui") 7 | ], 8 | 9 | daisyui: { 10 | darkTheme: "business", // name of one of the included themes for dark mode 11 | base: true, // applies background color and foreground color for root element by default 12 | styled: true, // include daisyUI colors and design decisions for all components 13 | utils: true, // adds responsive and modifier utility classes 14 | rtl: false, // rotate style direction from left-to-right to right-to-left. You also need to add dir="rtl" to your html tag and install `tailwindcss-flip` plugin for Tailwind CSS. 15 | prefix: "", // prefix for daisyUI classnames (components, modifiers and responsive class names. Not colors) 16 | logs: false, // S 17 | themes: ["business"] 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ESNext.Array", "ESNext", "ES2020", "DOM", "DOM.Iterable"], 6 | "module": "CommonJS", 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "inlineSourceMap": true, 11 | "inlineSources": true, 12 | /* Bundler mode */ 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "allowUmdGlobalAccess": true, 24 | "declarationMap": true, 25 | "composite": true, 26 | "outDir": "dist" 27 | }, 28 | "skipLibCheck": true, 29 | "include": ["**/*.ts", "**/*.tsx", "drizzle.config.ts"], 30 | "references": [{ "path": "./tsconfig.node.json" }] 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /types/types.ts: -------------------------------------------------------------------------------- 1 | import { type Formats } from "../shared/types/image"; 2 | import { type ActiveMonitor } from "../shared/types/monitor"; 3 | import { type rendererImage } from "../src/types/rendererTypes"; 4 | import { type imageSelectType } from "../database/schema"; 5 | 6 | export enum ACTIONS { 7 | NEXT_IMAGE = "next-image", 8 | NEXT_IMAGE_ALL = "next-image-all", 9 | PREVIOUS_IMAGE = "previous-image", 10 | PREVIOUS_IMAGE_ALL = "previous-image-all", 11 | START_PLAYLIST = "start-playlist", 12 | RANDOM_IMAGE = "random-image", 13 | STOP_DAEMON = "stop-daemon", 14 | PAUSE_PLAYLIST = "pause-playlist", 15 | PAUSE_PLAYLIST_ALL = "pause-playlist-all", 16 | RESUME_PLAYLIST = "resume-playlist", 17 | RESUME_PLAYLIST_ALL = "resume-playlist-all", 18 | STOP_PLAYLIST = "stop-playlist", 19 | UPDATE_CONFIG = "update-config", 20 | STOP_PLAYLIST_BY_NAME = "stop-playlist-by-name", 21 | STOP_PLAYLIST_BY_MONITOR_NAME = "stop-playlist-by-monitor-name", 22 | STOP_PLAYLIST_ON_REMOVED_DISPLAYS = "stop-playlist-on-removed-displays", 23 | STOP_PLAYLIST_ALL = "stop-playlist-all", 24 | SET_IMAGE = "set-image", 25 | ERROR = "error", 26 | DAEMON_CRASH = "daemon-crash", 27 | GET_INFO_PLAYLIST = "get-info-playlist", 28 | GET_INFO_ACTIVE_PLAYLIST = "get-info-active-playlist", 29 | GET_INFO = "get-info", 30 | GET_IMAGE_HISTORY = "get-image-history" 31 | } 32 | 33 | // refactor into using a discriminated type 34 | export type message = 35 | | { 36 | action: 37 | | ACTIONS.START_PLAYLIST 38 | | ACTIONS.STOP_PLAYLIST 39 | | ACTIONS.NEXT_IMAGE 40 | | ACTIONS.PREVIOUS_IMAGE 41 | | ACTIONS.PAUSE_PLAYLIST 42 | | ACTIONS.RESUME_PLAYLIST; 43 | playlist: { 44 | name: string; 45 | activeMonitor: ActiveMonitor; 46 | }; 47 | } 48 | | { 49 | action: ACTIONS.ERROR; 50 | error: { error: string }; 51 | } 52 | | { 53 | action: ACTIONS.SET_IMAGE; 54 | image?: imageSelectType | rendererImage; 55 | activeMonitor?: ActiveMonitor; 56 | } 57 | | { 58 | action: 59 | | ACTIONS.STOP_DAEMON 60 | | ACTIONS.RANDOM_IMAGE 61 | | ACTIONS.UPDATE_CONFIG 62 | | ACTIONS.STOP_PLAYLIST_ON_REMOVED_DISPLAYS 63 | | ACTIONS.GET_INFO_PLAYLIST 64 | | ACTIONS.GET_INFO_ACTIVE_PLAYLIST 65 | | ACTIONS.GET_INFO 66 | | ACTIONS.DAEMON_CRASH 67 | | ACTIONS.NEXT_IMAGE_ALL 68 | | ACTIONS.PREVIOUS_IMAGE_ALL 69 | | ACTIONS.RESUME_PLAYLIST_ALL 70 | | ACTIONS.PAUSE_PLAYLIST_ALL 71 | | ACTIONS.STOP_PLAYLIST_ALL 72 | | ACTIONS.GET_IMAGE_HISTORY; 73 | } 74 | | { 75 | action: ACTIONS.STOP_PLAYLIST_BY_NAME; 76 | playlist: { 77 | name: string; 78 | }; 79 | } 80 | | { 81 | action: ACTIONS.STOP_PLAYLIST_BY_MONITOR_NAME; 82 | monitors: string[]; 83 | }; 84 | 85 | export interface imageInPlaylist { 86 | name: string; 87 | time: number | null; 88 | } 89 | 90 | export interface imageMetadata { 91 | name: string; 92 | format: Formats; 93 | width: number; 94 | height: number; 95 | } 96 | -------------------------------------------------------------------------------- /utils/monitorUtils.ts: -------------------------------------------------------------------------------- 1 | import { initSwwwDaemon } from "../globals/startDaemons"; 2 | import { type wlr_output, type Monitor } from "../shared/types/monitor"; 3 | import { exec } from "node:child_process"; 4 | import { promisify } from "node:util"; 5 | import { parseResolution } from "../src/utils/utilities"; 6 | import { logger } from "../globals/setup"; 7 | const execPomisified = promisify(exec); 8 | 9 | function parseSwwwQuery(stdout: string) { 10 | const monitorsInfoString = stdout.split("\n"); 11 | const monitorsObjectArray = monitorsInfoString 12 | .filter(monitor => { 13 | return monitor !== ""; 14 | }) 15 | .map((monitor, index) => { 16 | const splitInfo = monitor.split(":"); 17 | const resolutionString = splitInfo[1].split(",")[0].trim(); 18 | const { width, height } = parseResolution(resolutionString); 19 | return { 20 | name: splitInfo[0].trim(), 21 | width, 22 | height, 23 | currentImage: splitInfo[4].trim(), 24 | position: index 25 | }; 26 | }); 27 | return monitorsObjectArray; 28 | } 29 | export async function getMonitorsInfo() { 30 | try { 31 | const { stdout } = await execPomisified("wlr-randr --json", { 32 | encoding: "utf-8" 33 | }); 34 | const monitors: wlr_output = JSON.parse(stdout); 35 | monitors.forEach(monitor => { 36 | monitor.modes = monitor.modes.filter(mode => mode.current); 37 | }); 38 | return monitors; 39 | } catch (error) { 40 | logger.error(error); 41 | return undefined; 42 | } 43 | } 44 | export async function getMonitors(): Promise { 45 | let stdout: string | undefined; 46 | let stderr: string | undefined; 47 | let tries = 0; 48 | while (tries < 3) { 49 | try { 50 | const result = await execPomisified("swww query", { 51 | encoding: "utf-8" 52 | }); 53 | stdout = result.stdout; 54 | stderr = result.stderr; 55 | break; 56 | } catch (error) { 57 | initSwwwDaemon(); 58 | tries++; 59 | } 60 | } 61 | if (stdout === undefined || stderr === undefined) { 62 | throw new Error("Could not query swww"); 63 | } 64 | const wlrOutput = await getMonitorsInfo(); 65 | const parsedSwwwQuery = parseSwwwQuery(stdout); 66 | if (stderr.length > 0 || wlrOutput === undefined) 67 | throw new Error("either wlrOutput is undefined or swww query failed"); 68 | return parsedSwwwQuery.map(swwwMonitor => { 69 | const matchingMonitor = wlrOutput.find(monitor => { 70 | return monitor.name === swwwMonitor.name; 71 | }); 72 | if (matchingMonitor === undefined) 73 | throw new Error("Could not reconcile wlr_output and swww info"); 74 | return { 75 | ...swwwMonitor, 76 | position: matchingMonitor.position 77 | }; 78 | }); 79 | } 80 | -------------------------------------------------------------------------------- /utils/notifications.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "node:child_process"; 2 | import { configuration } from "../globals/config"; 3 | import { logger } from "../globals/setup"; 4 | 5 | export function notifyImageSet(imageName: string, imagePath: string) { 6 | if (!configuration.app.config.notifications) return; 7 | const notifySend = `notify-send -u low -t 2000 -i "${imagePath}" -a "Waypaper Engine" "Waypaper Engine" "Setting image: ${imageName}"`; 8 | exec(notifySend, (err, _stdout, _stderr) => { 9 | if (err !== null) { 10 | logger.error(err); 11 | } 12 | }); 13 | } 14 | 15 | export function notify(message: string) { 16 | if (!configuration.app.config.notifications) return; 17 | const notifySend = `notify-send -u normal -t 2000 -a "Waypaper Engine" "Waypaper Engine" "${message}"`; 18 | exec(notifySend, (err, _stdout, _stderr) => { 19 | if (err !== null) { 20 | logger.error(err); 21 | } 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import electron from "vite-plugin-electron"; 3 | import renderer from "vite-plugin-electron-renderer"; 4 | import react from "@vitejs/plugin-react"; 5 | import { viteCommonjs, esbuildCommonjs } from "@originjs/vite-plugin-commonjs"; 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | build: { 9 | minify: false, 10 | sourcemap: "inline" 11 | }, 12 | plugins: [ 13 | react(), 14 | viteCommonjs(), 15 | electron([ 16 | { 17 | // Main-Process entry file of the Electron App. 18 | entry: "electron/main.ts", 19 | vite: { 20 | build: { 21 | minify: false, 22 | sourcemap: true 23 | } 24 | } 25 | }, 26 | { 27 | entry: "electron/preload.ts", 28 | onstart(options) { 29 | // Notify the Renderer-Process to reload the page when the Preload-Scripts build is complete, 30 | // instead of restarting the entire Electron App. 31 | options.reload(); 32 | } 33 | } 34 | ]), 35 | renderer() 36 | ], 37 | optimizeDeps: { 38 | esbuildOptions: { 39 | plugins: [ 40 | // Solves: 41 | // https://github.com/vitejs/vite/issues/5308 42 | // add the name of your package 43 | esbuildCommonjs(["sharp", "better-sqlite3", "pino"]) 44 | ] 45 | } 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /waypaper-engine.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=Waypaper Engine 4 | GenericName=Wallpaper Management 5 | Comment=An Electron-based graphical frontend for swww with batteries included! 6 | Exec=waypaper-engine run 7 | Icon=waypaper-engine 8 | Categories=Utility;Graphics; 9 | Terminal=false 10 | StartupNotify=true 11 | Keywords=wallpaper;playlist;electron; 12 | --------------------------------------------------------------------------------