├── .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 |

3 |
4 | 
5 | 
6 | 
7 | 
8 | 
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 | 
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 | 
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 | 
127 | 
128 | 
129 | 
130 | 
131 | 
132 | 
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 |
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 |
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 |
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 |

{
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 |

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 |

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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------