Compare commits
12 Commits
8bb97ed926
...
7a0bb14df4
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a0bb14df4 | |||
|
|
3cb2d9cb3e | ||
|
|
5317215c13 | ||
|
|
8d7e82bea5 | ||
|
|
4813941a5c | ||
|
|
190ff6a508 | ||
|
|
cf45a045ff | ||
|
|
87315a7bda | ||
|
|
88f2a02652 | ||
|
|
b45e7dbda9 | ||
|
|
d7a3088712 | ||
|
|
9532178b4c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ make
|
|||||||
.git
|
.git
|
||||||
build
|
build
|
||||||
.vscode
|
.vscode
|
||||||
|
test_config/slide.options.json
|
||||||
|
|||||||
61
README.md
61
README.md
@@ -18,7 +18,7 @@ This project is maintained by myself during my spare time. If you like and use i
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
slide [-t rotation_seconds] [-a aspect] [-o background_opacity(0..255)] [-b blur_radius] [-p image_folder|-i imageFile,...] [-r] [-O overlay_string] [-v] [--verbose] [--stretch] [-c path_to_config_json]
|
slide [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color overlay_color(#rrggbb)] [-a aspect] [-o background_opacity(0..255)] [-b blur_radius] [-p image_folder|-i imageFile,...] [-r] [-O overlay_string] [-v] [--verbose] [--stretch] [-c path_to_config_json]
|
||||||
```
|
```
|
||||||
|
|
||||||
* `image_folder`: where to search for images (.jpg files)
|
* `image_folder`: where to search for images (.jpg files)
|
||||||
@@ -30,10 +30,13 @@ slide [-t rotation_seconds] [-a aspect] [-o background_opacity(0..255)] [-b blur
|
|||||||
* `-S` for sorted rotation (files ordered by name, first images then subfolders)
|
* `-S` for sorted rotation (files ordered by name, first images then subfolders)
|
||||||
* `rotation_seconds(default=30)`: time until next random image is chosen from the given folder
|
* `rotation_seconds(default=30)`: time until next random image is chosen from the given folder
|
||||||
* `aspect(default=a)`: the required aspect ratio of the picture to display. Valid values are 'a' (all), 'l' (landscape), 'p' (portrait) and 'm' (monitor). Monitor will match the aspect ratio of the display we are running on.
|
* `aspect(default=a)`: the required aspect ratio of the picture to display. Valid values are 'a' (all), 'l' (landscape), 'p' (portrait) and 'm' (monitor). Monitor will match the aspect ratio of the display we are running on.
|
||||||
|
* `transition_seconds(default=1)`: time of image transition animation. Default is 1 second, and transition animation will be disabled if the value is set to 0
|
||||||
|
* `aspect(default=a)`: the required aspect ratio of the picture to display. Valid values are 'a' (all), 'l' (landscape) and 'p' (portrait)
|
||||||
* `background_opacity(default=150)`: opacity of the background filling image between 0 (black background) and 255
|
* `background_opacity(default=150)`: opacity of the background filling image between 0 (black background) and 255
|
||||||
* `blur_radius(default=20)`: blur radius of the background filling image
|
* `blur_radius(default=20)`: blur radius of the background filling image
|
||||||
* `-v` or `--verbose`: Verbose debug output when running, plus a thumbnail of the original image in the bottom left of the screen
|
* `-v` or `--verbose`: Verbose debug output when running, plus a thumbnail of the original image in the bottom left of the screen
|
||||||
* `--stretch`: When in aspect mode 'l','p' or 'm' crop the image rather than leaving a blurred background. For example, in landscape mode this will make images as wide as the screen and crop the top and bottom to fit.
|
* `--stretch`: When in aspect mode 'l','p' or 'm' crop the image rather than leaving a blurred background. For example, in landscape mode this will make images as wide as the screen and crop the top and bottom to fit.
|
||||||
|
* `-h` or `--overlay-color` the color of the overlay text, in the form of 3 or 6 digits hex rgb string prefixed by `#`, for example `#00FF00` or `#0F0` for color 🟢
|
||||||
* `-O` is used to create a overlay string.
|
* `-O` is used to create a overlay string.
|
||||||
* It defines overlays for all four edges in the order `top-left;top-right;bottom-left;bottom-right`
|
* It defines overlays for all four edges in the order `top-left;top-right;bottom-left;bottom-right`
|
||||||
* All edges overlays are separated by `;`
|
* All edges overlays are separated by `;`
|
||||||
@@ -49,7 +52,6 @@ slide [-t rotation_seconds] [-a aspect] [-o background_opacity(0..255)] [-b blur
|
|||||||
* `<dir>`directory of the current image
|
* `<dir>`directory of the current image
|
||||||
* `<path>`path to the current image without filename
|
* `<path>`path to the current image without filename
|
||||||
* Example: `slide -p ./images -O "20|60|Time: <time>;;;Picture taken at <exifdatetime>"`
|
* Example: `slide -p ./images -O "20|60|Time: <time>;;;Picture taken at <exifdatetime>"`
|
||||||
|
|
||||||
To exit the application, press escape. If you're using a touch display, touch all 4 corners at the same time.
|
To exit the application, press escape. If you're using a touch display, touch all 4 corners at the same time.
|
||||||
|
|
||||||
## Configuration file
|
## Configuration file
|
||||||
@@ -109,18 +111,71 @@ Supported keys and values in the JSON configuration are:
|
|||||||
* `overlay` : the same as the overlay command line argument
|
* `overlay` : the same as the overlay command line argument
|
||||||
* `shuffle` : set to true to enable shuffle mode for file display
|
* `shuffle` : set to true to enable shuffle mode for file display
|
||||||
* `recursive` : set to true to enable recursive mode for file display
|
* `recursive` : set to true to enable recursive mode for file display
|
||||||
* `sorted` : set to true to enable shuffle mode for file display
|
* `sorted` : set to true to enable sorted mode for file display
|
||||||
* `stretch` : set to true to enable, the same as the `--stretch` command line argument
|
* `stretch` : set to true to enable, the same as the `--stretch` command line argument
|
||||||
* `rotationSeconds` : the same as the `-t` command line argument
|
* `rotationSeconds` : the same as the `-t` command line argument
|
||||||
* `opacity` : the same as the command line `-o` argument
|
* `opacity` : the same as the command line `-o` argument
|
||||||
* `blur` : the same as the command line `-b` argument
|
* `blur` : the same as the command line `-b` argument
|
||||||
* `debug` : set to true to enable verbose output from the program
|
* `debug` : set to true to enable verbose output from the program
|
||||||
|
* `immich` : connect to an Immich server instead of a local path (see below)
|
||||||
* `scheduler` : this entry is an array of possible path values and associated settings. This key lets you manage display times/settings for a collection of paths. In the example above the top entry shows ONLY files from a Redit feed between 2 and 4pm, ONLY files from the `show_peak_times` folder from 8am to 10am and then 4pm to 7pm. At all other times it alternates displaying files in the `always_show_1` and `always_show_2` folder.
|
* `scheduler` : this entry is an array of possible path values and associated settings. This key lets you manage display times/settings for a collection of paths. In the example above the top entry shows ONLY files from a Redit feed between 2 and 4pm, ONLY files from the `show_peak_times` folder from 8am to 10am and then 4pm to 7pm. At all other times it alternates displaying files in the `always_show_1` and `always_show_2` folder.
|
||||||
* `exclusive` : When set to `true` only this entry will be used when it is in its valid time window.
|
* `exclusive` : When set to `true` only this entry will be used when it is in its valid time window.
|
||||||
* `times` : times is a JSON array of start and end times in which it is valid to display this image. The time is in the format HH:MM:SS and is based on the systems local time. If `start` isn't defined then it defaults to the start of the day, if `end` isn't defined it defaults to the end of the day.
|
* `times` : times is a JSON array of start and end times in which it is valid to display this image. The time is in the format HH:MM:SS and is based on the systems local time. If `start` isn't defined then it defaults to the start of the day, if `end` isn't defined it defaults to the end of the day.
|
||||||
* `path` : the path to image files
|
* `path` : the path to image files
|
||||||
* `stretch` : as above
|
* `stretch` : as above
|
||||||
|
|
||||||
|
### Immich configuration (lightweight + low power)
|
||||||
|
|
||||||
|
Immich uses an API key and a `/api` base path. This integration requests the asset search endpoint and downloads thumbnail images into a local cache before displaying them. That keeps bandwidth and power usage low while still letting `slide` do its normal scaling and transitions.
|
||||||
|
|
||||||
|
Example (single source):
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"immich": {
|
||||||
|
"url": "http://immich.local:2283",
|
||||||
|
"apiKey": "IMMICH_API_KEY",
|
||||||
|
"albumId": "b7f3c8b2-2e3f-4b32-9dc9-8c3f8b0a3ef7",
|
||||||
|
"size": "fullsize",
|
||||||
|
"order": "desc",
|
||||||
|
"pageSize": 200,
|
||||||
|
"maxAssets": 1000,
|
||||||
|
"cachePath": "~/.cache/slide/immich",
|
||||||
|
"cacheMaxMB": 512,
|
||||||
|
"includeArchived": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (scheduler entry):
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"scheduler": [
|
||||||
|
{
|
||||||
|
"exclusive": true,
|
||||||
|
"immich": {
|
||||||
|
"url": "http://immich.local:2283",
|
||||||
|
"apiKey": "IMMICH_API_KEY",
|
||||||
|
"albumIds": ["b7f3c8b2-2e3f-4b32-9dc9-8c3f8b0a3ef7"],
|
||||||
|
"size": "fullsize"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Immich settings:
|
||||||
|
* `url`: base Immich server URL (the integration appends `/api` automatically if missing).
|
||||||
|
* `apiKey`: Immich API key (needs `asset.view`, and `asset.download` if `size` is `original`).
|
||||||
|
* `albumId` or `albumIds`: optional album filters.
|
||||||
|
* `size`: `"fullsize"`, `"preview"`, `"thumbnail"`, or `"original"` (original uses the download endpoint).
|
||||||
|
* `order`: `"asc"` or `"desc"` ordering for asset search.
|
||||||
|
* `pageSize`: assets fetched per page.
|
||||||
|
* `maxAssets`: cap on total assets fetched (0 means no cap).
|
||||||
|
* `cachePath`: local cache directory for downloaded thumbnails.
|
||||||
|
* `cacheMaxMB`: maximum cache size in MB (0 disables cleanup).
|
||||||
|
* `includeArchived`: include archived assets in search results.
|
||||||
|
When `immich` is set on an entry, `path` and `imageList` are ignored.
|
||||||
|
|
||||||
## Folder Options file
|
## Folder Options file
|
||||||
When using the default or recursive folder mode we support having per folder display options. The options are stored in a file called "options.json" in the images folder and support a subset of the applications configuration settings:
|
When using the default or recursive folder mode we support having per folder display options. The options are stored in a file called "options.json" in the images folder and support a subset of the applications configuration settings:
|
||||||
```
|
```
|
||||||
|
|||||||
35
sbin/build_deb.sh
Normal file
35
sbin/build_deb.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
VERSION="${1:-${VERSION:-0.0.0}}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
ARCH="${ARCH:-$(dpkg --print-architecture)}"
|
||||||
|
|
||||||
|
PACKAGE_NAME="slide"
|
||||||
|
BUILD_DIR="$ROOT_DIR/build"
|
||||||
|
DIST_DIR="$ROOT_DIR/dist"
|
||||||
|
STAGE_DIR="$BUILD_DIR/deb"
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
make build
|
||||||
|
|
||||||
|
rm -rf "$STAGE_DIR"
|
||||||
|
mkdir -p "$STAGE_DIR/DEBIAN" "$STAGE_DIR/usr/local/bin" "$DIST_DIR"
|
||||||
|
|
||||||
|
install -m 0755 "$BUILD_DIR/slide" "$STAGE_DIR/usr/local/bin/slide"
|
||||||
|
|
||||||
|
cat > "$STAGE_DIR/DEBIAN/control" <<EOF
|
||||||
|
Package: ${PACKAGE_NAME}
|
||||||
|
Version: ${VERSION}
|
||||||
|
Section: graphics
|
||||||
|
Priority: optional
|
||||||
|
Architecture: ${ARCH}
|
||||||
|
Maintainer: slide build
|
||||||
|
Depends: libqt5core5a, libqt5gui5, libqt5widgets5, libqt5network5, libexif12, qt5-image-formats-plugins
|
||||||
|
Description: Lightweight slideshow for photo frames
|
||||||
|
Simple, lightweight slideshow designed for low power devices.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
dpkg-deb --build "$STAGE_DIR" "$DIST_DIR/${PACKAGE_NAME}_${VERSION}_${ARCH}.deb"
|
||||||
@@ -48,7 +48,76 @@ void SetJSONBool(bool &value, QJsonObject jsonDoc, const char *key) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SetJSONInt(int &value, QJsonObject jsonDoc, const char *key) {
|
||||||
|
if(jsonDoc.contains(key) && jsonDoc[key].isDouble())
|
||||||
|
{
|
||||||
|
value = (int)jsonDoc[key].toDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> ParseJSONStrings(QJsonObject jsonDoc, const char *key) {
|
||||||
|
std::vector<std::string> values;
|
||||||
|
if(jsonDoc.contains(key) && jsonDoc[key].isArray())
|
||||||
|
{
|
||||||
|
QJsonArray jsonArray = jsonDoc[key].toArray();
|
||||||
|
foreach (const QJsonValue & value, jsonArray)
|
||||||
|
{
|
||||||
|
if (value.isString())
|
||||||
|
{
|
||||||
|
values.push_back(value.toString().toStdString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImmichConfig ParseImmichConfigObject(QJsonObject immichJson) {
|
||||||
|
ImmichConfig config;
|
||||||
|
|
||||||
|
std::string url = ParseJSONString(immichJson, "url");
|
||||||
|
std::string apiKey = ParseJSONString(immichJson, "apiKey");
|
||||||
|
if(!url.empty())
|
||||||
|
config.url = url;
|
||||||
|
if(!apiKey.empty())
|
||||||
|
config.apiKey = apiKey;
|
||||||
|
|
||||||
|
std::string size = ParseJSONString(immichJson, "size");
|
||||||
|
if(!size.empty())
|
||||||
|
config.size = size;
|
||||||
|
|
||||||
|
std::string order = ParseJSONString(immichJson, "order");
|
||||||
|
if(!order.empty())
|
||||||
|
config.order = order;
|
||||||
|
|
||||||
|
std::string cachePath = ParseJSONString(immichJson, "cachePath");
|
||||||
|
if(!cachePath.empty())
|
||||||
|
config.cachePath = cachePath;
|
||||||
|
|
||||||
|
std::string albumId = ParseJSONString(immichJson, "albumId");
|
||||||
|
if(!albumId.empty())
|
||||||
|
config.albumIds.push_back(albumId);
|
||||||
|
|
||||||
|
std::vector<std::string> albumIds = ParseJSONStrings(immichJson, "albumIds");
|
||||||
|
if(albumIds.size() > 0)
|
||||||
|
config.albumIds = albumIds;
|
||||||
|
|
||||||
|
SetJSONInt(config.pageSize, immichJson, "pageSize");
|
||||||
|
SetJSONInt(config.maxAssets, immichJson, "maxAssets");
|
||||||
|
SetJSONInt(config.cacheMaxMB, immichJson, "cacheMaxMB");
|
||||||
|
SetJSONBool(config.includeArchived, immichJson, "includeArchived");
|
||||||
|
|
||||||
|
if(!config.url.empty() && !config.apiKey.empty())
|
||||||
|
config.enabled = true;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
Config loadConfiguration(const std::string &configFilePath, const Config ¤tConfig) {
|
Config loadConfiguration(const std::string &configFilePath, const Config ¤tConfig) {
|
||||||
|
if(configFilePath.empty())
|
||||||
|
{
|
||||||
|
return currentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
QString jsonFile(configFilePath.c_str());
|
QString jsonFile(configFilePath.c_str());
|
||||||
QDir directory;
|
QDir directory;
|
||||||
if(!directory.exists(jsonFile))
|
if(!directory.exists(jsonFile))
|
||||||
@@ -179,6 +248,11 @@ QVector<PathEntry> parsePathEntry(QJsonObject &jsonMainDoc, bool baseRecursive,
|
|||||||
|
|
||||||
SetJSONBool(entry.baseDisplayOptions.fitAspectAxisToWindow, schedulerJson, "stretch");
|
SetJSONBool(entry.baseDisplayOptions.fitAspectAxisToWindow, schedulerJson, "stretch");
|
||||||
|
|
||||||
|
if(schedulerJson.contains("immich") && schedulerJson["immich"].isObject())
|
||||||
|
{
|
||||||
|
entry.immich = ParseImmichConfigObject(schedulerJson["immich"].toObject());
|
||||||
|
}
|
||||||
|
|
||||||
std::string pathString = ParseJSONString(schedulerJson, "path");
|
std::string pathString = ParseJSONString(schedulerJson, "path");
|
||||||
if(!pathString.empty()) {
|
if(!pathString.empty()) {
|
||||||
entry.path = pathString;
|
entry.path = pathString;
|
||||||
@@ -218,6 +292,11 @@ QVector<PathEntry> parsePathEntry(QJsonObject &jsonMainDoc, bool baseRecursive,
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
||||||
|
if(commandLineConfig.configPath.empty())
|
||||||
|
{
|
||||||
|
return commandLineConfig;
|
||||||
|
}
|
||||||
|
|
||||||
QString jsonFile = getAppConfigFilePath(commandLineConfig.configPath);
|
QString jsonFile = getAppConfigFilePath(commandLineConfig.configPath);
|
||||||
QDir directory;
|
QDir directory;
|
||||||
if(!directory.exists(jsonFile))
|
if(!directory.exists(jsonFile))
|
||||||
@@ -255,6 +334,10 @@ AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
|||||||
entry.recursive = baseRecursive;
|
entry.recursive = baseRecursive;
|
||||||
entry.sorted = baseSorted;
|
entry.sorted = baseSorted;
|
||||||
entry.shuffle = baseShuffle;
|
entry.shuffle = baseShuffle;
|
||||||
|
if(jsonDoc.contains("immich") && jsonDoc["immich"].isObject())
|
||||||
|
{
|
||||||
|
entry.immich = ParseImmichConfigObject(jsonDoc["immich"].toObject());
|
||||||
|
}
|
||||||
std::string pathString = ParseJSONString(jsonDoc, "path");
|
std::string pathString = ParseJSONString(jsonDoc, "path");
|
||||||
if(!pathString.empty())
|
if(!pathString.empty())
|
||||||
{
|
{
|
||||||
@@ -272,6 +355,10 @@ AppConfig loadAppConfiguration(const AppConfig &commandLineConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Config getConfigurationForFolder(const std::string &folderPath, const Config ¤tConfig) {
|
Config getConfigurationForFolder(const std::string &folderPath, const Config ¤tConfig) {
|
||||||
|
if(folderPath.empty())
|
||||||
|
{
|
||||||
|
return currentConfig;
|
||||||
|
}
|
||||||
QDir directory(folderPath.c_str());
|
QDir directory(folderPath.c_str());
|
||||||
QString jsonFile = directory.filePath(QString("options.json"));
|
QString jsonFile = directory.filePath(QString("options.json"));
|
||||||
if(directory.exists(jsonFile))
|
if(directory.exists(jsonFile))
|
||||||
|
|||||||
@@ -4,11 +4,56 @@
|
|||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include "imagestructs.h"
|
#include "imagestructs.h"
|
||||||
#include <QVector>
|
#include <QVector>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct ImmichConfig {
|
||||||
|
bool enabled = false;
|
||||||
|
std::string url = "";
|
||||||
|
std::string apiKey = "";
|
||||||
|
std::vector<std::string> albumIds;
|
||||||
|
std::string size = "fullsize";
|
||||||
|
std::string order = "desc";
|
||||||
|
int pageSize = 200;
|
||||||
|
int maxAssets = 1000;
|
||||||
|
std::string cachePath = "";
|
||||||
|
int cacheMaxMB = 512;
|
||||||
|
bool includeArchived = false;
|
||||||
|
|
||||||
|
bool operator==(const ImmichConfig &b) const
|
||||||
|
{
|
||||||
|
if (enabled != b.enabled)
|
||||||
|
return false;
|
||||||
|
if (url != b.url || apiKey != b.apiKey)
|
||||||
|
return false;
|
||||||
|
if (size != b.size || order != b.order)
|
||||||
|
return false;
|
||||||
|
if (pageSize != b.pageSize || maxAssets != b.maxAssets)
|
||||||
|
return false;
|
||||||
|
if (cachePath != b.cachePath || cacheMaxMB != b.cacheMaxMB)
|
||||||
|
return false;
|
||||||
|
if (includeArchived != b.includeArchived)
|
||||||
|
return false;
|
||||||
|
if (albumIds.size() != b.albumIds.size())
|
||||||
|
return false;
|
||||||
|
for (size_t i = 0; i < albumIds.size(); ++i)
|
||||||
|
{
|
||||||
|
if (albumIds[i] != b.albumIds[i])
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator!=(const ImmichConfig &b) const
|
||||||
|
{
|
||||||
|
return !operator==(b);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// configuration options that apply to an image/folder of images
|
// configuration options that apply to an image/folder of images
|
||||||
struct Config {
|
struct Config {
|
||||||
public:
|
public:
|
||||||
unsigned int rotationSeconds = 30;
|
unsigned int rotationSeconds = 30;
|
||||||
|
unsigned int transitionTime = 1;
|
||||||
int blurRadius = -1;
|
int blurRadius = -1;
|
||||||
int backgroundOpacity = -1;
|
int backgroundOpacity = -1;
|
||||||
ImageDisplayOptions baseDisplayOptions;
|
ImageDisplayOptions baseDisplayOptions;
|
||||||
@@ -20,6 +65,7 @@ struct PathEntry {
|
|||||||
std::string imageList = "";
|
std::string imageList = "";
|
||||||
bool exclusive = false; // only use this entry when it is valid, skip others
|
bool exclusive = false; // only use this entry when it is valid, skip others
|
||||||
|
|
||||||
|
ImmichConfig immich;
|
||||||
bool recursive = false;
|
bool recursive = false;
|
||||||
bool shuffle = false;
|
bool shuffle = false;
|
||||||
bool sorted = false;
|
bool sorted = false;
|
||||||
@@ -39,6 +85,8 @@ struct PathEntry {
|
|||||||
return true;
|
return true;
|
||||||
if (b.path != path || b.imageList != imageList)
|
if (b.path != path || b.imageList != imageList)
|
||||||
return true;
|
return true;
|
||||||
|
if (b.immich != immich)
|
||||||
|
return true;
|
||||||
if (b.baseDisplayOptions.timeWindows.count() != baseDisplayOptions.timeWindows.count())
|
if (b.baseDisplayOptions.timeWindows.count() != baseDisplayOptions.timeWindows.count())
|
||||||
return true;
|
return true;
|
||||||
for(int i = 0; i < baseDisplayOptions.timeWindows.count(); ++i)
|
for(int i = 0; i < baseDisplayOptions.timeWindows.count(); ++i)
|
||||||
|
|||||||
337
src/immichclient.cpp
Normal file
337
src/immichclient.cpp
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
#include "immichclient.h"
|
||||||
|
#include "logger.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUrlQuery>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
const int kMetadataTimeoutMs = 15000;
|
||||||
|
const int kAssetTimeoutMs = 30000;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImmichClient::ImmichClient(const ImmichConfig &configIn)
|
||||||
|
: config(configIn)
|
||||||
|
{}
|
||||||
|
|
||||||
|
QUrl ImmichClient::apiUrl(const QString &path) const
|
||||||
|
{
|
||||||
|
QString base = QString::fromStdString(config.url).trimmed();
|
||||||
|
if (base.endsWith("/"))
|
||||||
|
base.chop(1);
|
||||||
|
if (!base.endsWith("/api"))
|
||||||
|
base += "/api";
|
||||||
|
return QUrl(base + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
QNetworkRequest ImmichClient::makeRequest(const QUrl &url) const
|
||||||
|
{
|
||||||
|
QNetworkRequest request(url);
|
||||||
|
request.setRawHeader("x-api-key", QByteArray::fromStdString(config.apiKey));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImmichClient::waitForReply(QNetworkReply *reply, QByteArray &data, QString *contentType, int timeoutMs)
|
||||||
|
{
|
||||||
|
QEventLoop loop;
|
||||||
|
QTimer timer;
|
||||||
|
timer.setSingleShot(true);
|
||||||
|
QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||||
|
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||||
|
timer.start(timeoutMs);
|
||||||
|
loop.exec();
|
||||||
|
|
||||||
|
if (!timer.isActive())
|
||||||
|
{
|
||||||
|
reply->abort();
|
||||||
|
reply->deleteLater();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType)
|
||||||
|
*contentType = reply->header(QNetworkRequest::ContentTypeHeader).toString();
|
||||||
|
data = reply->readAll();
|
||||||
|
bool ok = reply->error() == QNetworkReply::NoError;
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
Log("Immich request failed: ", reply->errorString().toStdString());
|
||||||
|
}
|
||||||
|
reply->deleteLater();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray ImmichClient::postJson(const QUrl &url, const QJsonObject &body, QString *contentType, int timeoutMs)
|
||||||
|
{
|
||||||
|
QNetworkRequest request = makeRequest(url);
|
||||||
|
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||||
|
request.setRawHeader("Accept", "application/json");
|
||||||
|
QNetworkReply *reply = manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||||
|
QByteArray data;
|
||||||
|
if (!waitForReply(reply, data, contentType, timeoutMs))
|
||||||
|
return QByteArray();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray ImmichClient::getBytes(const QUrl &url, QString *contentType, int timeoutMs)
|
||||||
|
{
|
||||||
|
QNetworkRequest request = makeRequest(url);
|
||||||
|
request.setRawHeader("Accept", "*/*");
|
||||||
|
QNetworkReply *reply = manager.get(request);
|
||||||
|
QByteArray data;
|
||||||
|
if (!waitForReply(reply, data, contentType, timeoutMs))
|
||||||
|
return QByteArray();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<ImmichAsset> ImmichClient::fetchAssets()
|
||||||
|
{
|
||||||
|
QVector<ImmichAsset> assets;
|
||||||
|
if (!config.enabled)
|
||||||
|
{
|
||||||
|
Log("Immich config is missing url or apiKey.");
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pageSize = config.pageSize > 0 ? config.pageSize : 200;
|
||||||
|
int maxAssets = config.maxAssets;
|
||||||
|
bool triedZero = false;
|
||||||
|
int page = 1;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
QJsonObject body;
|
||||||
|
body["page"] = page;
|
||||||
|
body["size"] = pageSize;
|
||||||
|
body["type"] = "IMAGE";
|
||||||
|
body["order"] = QString::fromStdString(config.order);
|
||||||
|
if (config.includeArchived)
|
||||||
|
body["withArchived"] = true;
|
||||||
|
if (config.albumIds.size() > 0)
|
||||||
|
{
|
||||||
|
QJsonArray ids;
|
||||||
|
for (const auto &id : config.albumIds)
|
||||||
|
ids.append(QString::fromStdString(id));
|
||||||
|
body["albumIds"] = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray response = postJson(apiUrl("/search/metadata"), body, nullptr, kMetadataTimeoutMs);
|
||||||
|
if (response.isEmpty())
|
||||||
|
break;
|
||||||
|
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(response);
|
||||||
|
if (!doc.isObject())
|
||||||
|
break;
|
||||||
|
|
||||||
|
QJsonObject root = doc.object();
|
||||||
|
QJsonObject assetsObj = root["assets"].toObject();
|
||||||
|
QJsonArray items = assetsObj["items"].toArray();
|
||||||
|
int total = assetsObj["total"].toInt();
|
||||||
|
if (items.isEmpty())
|
||||||
|
{
|
||||||
|
if (total > 0 && page == 1 && !triedZero)
|
||||||
|
{
|
||||||
|
triedZero = true;
|
||||||
|
page = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto &value : items)
|
||||||
|
{
|
||||||
|
QJsonObject item = value.toObject();
|
||||||
|
QString id = item["id"].toString();
|
||||||
|
if (id.isEmpty())
|
||||||
|
continue;
|
||||||
|
ImmichAsset asset;
|
||||||
|
asset.id = id;
|
||||||
|
asset.originalFileName = item["originalFileName"].toString();
|
||||||
|
assets.append(asset);
|
||||||
|
if (maxAssets > 0 && assets.size() >= maxAssets)
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.size() < pageSize)
|
||||||
|
break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImmichClient::downloadAsset(const QString &assetId, QByteArray &data, QString &contentType)
|
||||||
|
{
|
||||||
|
if (!config.enabled)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
QString size = QString::fromStdString(config.size).trimmed().toLower();
|
||||||
|
if (size.isEmpty())
|
||||||
|
size = "fullsize";
|
||||||
|
|
||||||
|
QUrl url;
|
||||||
|
if (size == "original" || size == "download")
|
||||||
|
{
|
||||||
|
url = apiUrl("/assets/" + assetId + "/download");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (size != "fullsize" && size != "preview" && size != "thumbnail")
|
||||||
|
{
|
||||||
|
Log("Immich size '", size.toStdString(), "' not recognized. Defaulting to fullsize.");
|
||||||
|
size = "fullsize";
|
||||||
|
}
|
||||||
|
url = apiUrl("/assets/" + assetId + "/thumbnail");
|
||||||
|
QUrlQuery query;
|
||||||
|
query.addQueryItem("size", size);
|
||||||
|
url.setQuery(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray payload = getBytes(url, &contentType, kAssetTimeoutMs);
|
||||||
|
if (payload.isEmpty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
data = payload;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImmichAssetCache::ImmichAssetCache(const ImmichConfig &config)
|
||||||
|
{
|
||||||
|
QString rawPath = QString::fromStdString(config.cachePath);
|
||||||
|
cacheDirPath = resolveCachePath(rawPath);
|
||||||
|
if (config.cacheMaxMB > 0)
|
||||||
|
cacheMaxBytes = static_cast<qint64>(config.cacheMaxMB) * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ImmichAssetCache::resolveCachePath(const QString &rawPath) const
|
||||||
|
{
|
||||||
|
if (rawPath.isEmpty())
|
||||||
|
{
|
||||||
|
return QDir::homePath() + "/.cache/slide/immich";
|
||||||
|
}
|
||||||
|
if (rawPath.startsWith("~"))
|
||||||
|
{
|
||||||
|
return QDir::homePath() + rawPath.mid(1);
|
||||||
|
}
|
||||||
|
return rawPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImmichAssetCache::ensureCacheDir() const
|
||||||
|
{
|
||||||
|
QDir dir(cacheDirPath);
|
||||||
|
if (!dir.exists())
|
||||||
|
dir.mkpath(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ImmichAssetCache::findExisting(const QString &assetId) const
|
||||||
|
{
|
||||||
|
QDir dir(cacheDirPath);
|
||||||
|
QStringList matches = dir.entryList(QStringList() << (assetId + "_*"), QDir::Files, QDir::Time);
|
||||||
|
if (matches.isEmpty())
|
||||||
|
return "";
|
||||||
|
return dir.filePath(matches.first());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ImmichAssetCache::sanitizeFileName(const QString &name) const
|
||||||
|
{
|
||||||
|
QString safe = name;
|
||||||
|
safe.replace(QRegularExpression("[^A-Za-z0-9_.-]"), "_");
|
||||||
|
if (safe.isEmpty())
|
||||||
|
safe = "asset";
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ImmichAssetCache::extensionForContentType(const QString &contentType) const
|
||||||
|
{
|
||||||
|
if (contentType.contains("jpeg", Qt::CaseInsensitive))
|
||||||
|
return "jpg";
|
||||||
|
if (contentType.contains("png", Qt::CaseInsensitive))
|
||||||
|
return "png";
|
||||||
|
if (contentType.contains("webp", Qt::CaseInsensitive))
|
||||||
|
return "webp";
|
||||||
|
if (contentType.contains("gif", Qt::CaseInsensitive))
|
||||||
|
return "gif";
|
||||||
|
return "img";
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 ImmichAssetCache::calculateCacheSize() const
|
||||||
|
{
|
||||||
|
QDir dir(cacheDirPath);
|
||||||
|
QFileInfoList files = dir.entryInfoList(QDir::Files, QDir::Time);
|
||||||
|
qint64 total = 0;
|
||||||
|
for (const auto &info : files)
|
||||||
|
total += info.size();
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImmichAssetCache::enforceCacheLimit()
|
||||||
|
{
|
||||||
|
if (cacheMaxBytes <= 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
QDir dir(cacheDirPath);
|
||||||
|
QFileInfoList files = dir.entryInfoList(QDir::Files, QDir::Time);
|
||||||
|
qint64 total = 0;
|
||||||
|
for (const auto &info : files)
|
||||||
|
total += info.size();
|
||||||
|
|
||||||
|
for (int i = files.size() - 1; i >= 0 && total > cacheMaxBytes; --i)
|
||||||
|
{
|
||||||
|
total -= files[i].size();
|
||||||
|
QFile::remove(files[i].filePath());
|
||||||
|
}
|
||||||
|
cacheSizeBytes = total;
|
||||||
|
cacheSizeKnown = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ImmichAssetCache::getCachedPath(const QString &assetId, const QString &assetName, ImmichClient &client)
|
||||||
|
{
|
||||||
|
ensureCacheDir();
|
||||||
|
|
||||||
|
QString existing = findExisting(assetId);
|
||||||
|
if (!existing.isEmpty())
|
||||||
|
return existing;
|
||||||
|
|
||||||
|
QByteArray data;
|
||||||
|
QString contentType;
|
||||||
|
if (!client.downloadAsset(assetId, data, contentType))
|
||||||
|
return "";
|
||||||
|
|
||||||
|
QString safeName = sanitizeFileName(assetName);
|
||||||
|
QString extension = extensionForContentType(contentType);
|
||||||
|
QString filename = assetId + "_" + safeName + "." + extension;
|
||||||
|
|
||||||
|
QDir dir(cacheDirPath);
|
||||||
|
QString filePath = dir.filePath(filename);
|
||||||
|
|
||||||
|
QSaveFile file(filePath);
|
||||||
|
if (!file.open(QIODevice::WriteOnly))
|
||||||
|
return "";
|
||||||
|
file.write(data);
|
||||||
|
if (!file.commit())
|
||||||
|
return "";
|
||||||
|
|
||||||
|
if (cacheMaxBytes > 0)
|
||||||
|
{
|
||||||
|
if (!cacheSizeKnown)
|
||||||
|
{
|
||||||
|
cacheSizeBytes = calculateCacheSize();
|
||||||
|
cacheSizeKnown = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cacheSizeBytes += data.size();
|
||||||
|
}
|
||||||
|
if (cacheSizeBytes > cacheMaxBytes)
|
||||||
|
enforceCacheLimit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
56
src/immichclient.h
Normal file
56
src/immichclient.h
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#ifndef IMMICHCLIENT_H
|
||||||
|
#define IMMICHCLIENT_H
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QString>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
#include "appconfig.h"
|
||||||
|
|
||||||
|
struct ImmichAsset {
|
||||||
|
QString id;
|
||||||
|
QString originalFileName;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImmichClient {
|
||||||
|
public:
|
||||||
|
explicit ImmichClient(const ImmichConfig &config);
|
||||||
|
QVector<ImmichAsset> fetchAssets();
|
||||||
|
bool downloadAsset(const QString &assetId, QByteArray &data, QString &contentType);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QUrl apiUrl(const QString &path) const;
|
||||||
|
QNetworkRequest makeRequest(const QUrl &url) const;
|
||||||
|
QByteArray postJson(const QUrl &url, const QJsonObject &body, QString *contentType, int timeoutMs);
|
||||||
|
QByteArray getBytes(const QUrl &url, QString *contentType, int timeoutMs);
|
||||||
|
bool waitForReply(QNetworkReply *reply, QByteArray &data, QString *contentType, int timeoutMs);
|
||||||
|
|
||||||
|
ImmichConfig config;
|
||||||
|
QNetworkAccessManager manager;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImmichAssetCache {
|
||||||
|
public:
|
||||||
|
explicit ImmichAssetCache(const ImmichConfig &config);
|
||||||
|
QString getCachedPath(const QString &assetId, const QString &assetName, ImmichClient &client);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString resolveCachePath(const QString &rawPath) const;
|
||||||
|
QString findExisting(const QString &assetId) const;
|
||||||
|
QString sanitizeFileName(const QString &name) const;
|
||||||
|
QString extensionForContentType(const QString &contentType) const;
|
||||||
|
void ensureCacheDir() const;
|
||||||
|
void enforceCacheLimit();
|
||||||
|
qint64 calculateCacheSize() const;
|
||||||
|
|
||||||
|
QString cacheDirPath;
|
||||||
|
qint64 cacheMaxBytes = 0;
|
||||||
|
bool cacheSizeKnown = false;
|
||||||
|
qint64 cacheSizeBytes = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // IMMICHCLIENT_H
|
||||||
47
src/immichpathtraverser.cpp
Normal file
47
src/immichpathtraverser.cpp
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#include "immichpathtraverser.h"
|
||||||
|
#include "logger.h"
|
||||||
|
|
||||||
|
ImmichPathTraverser::ImmichPathTraverser(const ImmichConfig &configIn)
|
||||||
|
: PathTraverser(""),
|
||||||
|
config(configIn),
|
||||||
|
client(configIn),
|
||||||
|
cache(configIn)
|
||||||
|
{
|
||||||
|
loadAssets();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImmichPathTraverser::~ImmichPathTraverser() {}
|
||||||
|
|
||||||
|
void ImmichPathTraverser::loadAssets()
|
||||||
|
{
|
||||||
|
assetIds.clear();
|
||||||
|
assetNames.clear();
|
||||||
|
QVector<ImmichAsset> assets = client.fetchAssets();
|
||||||
|
for (const auto &asset : assets)
|
||||||
|
{
|
||||||
|
if (asset.id.isEmpty())
|
||||||
|
continue;
|
||||||
|
assetIds.append(asset.id);
|
||||||
|
assetNames.insert(asset.id, asset.originalFileName);
|
||||||
|
}
|
||||||
|
Log("Immich assets loaded: ", assetIds.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList ImmichPathTraverser::getImages() const
|
||||||
|
{
|
||||||
|
return assetIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string ImmichPathTraverser::getImagePath(const std::string image) const
|
||||||
|
{
|
||||||
|
QString assetId = QString::fromStdString(image);
|
||||||
|
QString name = assetNames.value(assetId);
|
||||||
|
QString path = cache.getCachedPath(assetId, name, client);
|
||||||
|
return path.toStdString();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageDisplayOptions ImmichPathTraverser::UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& options) const
|
||||||
|
{
|
||||||
|
Q_UNUSED(filename);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
28
src/immichpathtraverser.h
Normal file
28
src/immichpathtraverser.h
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#ifndef IMMICHPATHTRAVERSER_H
|
||||||
|
#define IMMICHPATHTRAVERSER_H
|
||||||
|
|
||||||
|
#include "pathtraverser.h"
|
||||||
|
#include "immichclient.h"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
|
||||||
|
class ImmichPathTraverser : public PathTraverser
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ImmichPathTraverser(const ImmichConfig &config);
|
||||||
|
virtual ~ImmichPathTraverser();
|
||||||
|
QStringList getImages() const override;
|
||||||
|
virtual const std::string getImagePath(const std::string image) const override;
|
||||||
|
virtual ImageDisplayOptions UpdateOptionsForImage(const std::string& filename, const ImageDisplayOptions& options) const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void loadAssets();
|
||||||
|
|
||||||
|
ImmichConfig config;
|
||||||
|
mutable ImmichClient client;
|
||||||
|
mutable ImmichAssetCache cache;
|
||||||
|
QStringList assetIds;
|
||||||
|
QHash<QString, QString> assetNames;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // IMMICHPATHTRAVERSER_H
|
||||||
25
src/main.cpp
25
src/main.cpp
@@ -2,12 +2,12 @@
|
|||||||
#include "imageselector.h"
|
#include "imageselector.h"
|
||||||
#include "imageswitcher.h"
|
#include "imageswitcher.h"
|
||||||
#include "pathtraverser.h"
|
#include "pathtraverser.h"
|
||||||
|
#include "immichpathtraverser.h"
|
||||||
#include "overlay.h"
|
#include "overlay.h"
|
||||||
#include "appconfig.h"
|
#include "appconfig.h"
|
||||||
#include "logger.h"
|
#include "logger.h"
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QNetworkAccessManager>
|
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <sys/file.h>
|
#include <sys/file.h>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
void usage(std::string programName) {
|
void usage(std::string programName) {
|
||||||
std::cerr << "Usage: " << programName << " [-t rotation_seconds] [-a aspect('l','p','a', 'm')] [-o background_opacity(0..255)] [-b blur_radius] -p image_folder [-r] [-s] [-v] [--verbose] [--stretch] [-c config_file_path]" << std::endl;
|
std::cerr << "Usage: " << programName << " [-t rotation_seconds] [-T transition_seconds] [-h/--overlay-color #rrggbb] [-a aspect('l','p','a', 'm')] [-o background_opacity(0..255)] [-b blur_radius] -p image_folder [-r] [-s] [-S] [-v] [--verbose] [--stretch] [-c config_file_path]" << std::endl;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool parseCommandLine(AppConfig &appConfig, int argc, char *argv[]) {
|
bool parseCommandLine(AppConfig &appConfig, int argc, char *argv[]) {
|
||||||
@@ -34,7 +34,7 @@ bool parseCommandLine(AppConfig &appConfig, int argc, char *argv[]) {
|
|||||||
{"overlay-color", required_argument, 0, 'h'},
|
{"overlay-color", required_argument, 0, 'h'},
|
||||||
};
|
};
|
||||||
int option_index = 0;
|
int option_index = 0;
|
||||||
while ((opt = getopt_long(argc, argv, "b:p:t:o:O:a:i:c:h:rsSv", long_options, &option_index)) != -1) {
|
while ((opt = getopt_long(argc, argv, "b:p:t:T:o:O:a:i:c:h:rsSv", long_options, &option_index)) != -1) {
|
||||||
switch (opt) {
|
switch (opt) {
|
||||||
case 0:
|
case 0:
|
||||||
/* If this option set a flag, do nothing else now. */
|
/* If this option set a flag, do nothing else now. */
|
||||||
@@ -61,6 +61,9 @@ bool parseCommandLine(AppConfig &appConfig, int argc, char *argv[]) {
|
|||||||
case 't':
|
case 't':
|
||||||
appConfig.rotationSeconds = atoi(optarg);
|
appConfig.rotationSeconds = atoi(optarg);
|
||||||
break;
|
break;
|
||||||
|
case 'T':
|
||||||
|
appConfig.transitionTime =atoi(optarg);
|
||||||
|
break;
|
||||||
case 'b':
|
case 'b':
|
||||||
appConfig.blurRadius = atoi(optarg);
|
appConfig.blurRadius = atoi(optarg);
|
||||||
break;
|
break;
|
||||||
@@ -129,6 +132,8 @@ void ConfigureWindowFromSettings(MainWindow &w, const AppConfig &appConfig)
|
|||||||
w.setBackgroundOpacity(appConfig.backgroundOpacity);
|
w.setBackgroundOpacity(appConfig.backgroundOpacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.setTransitionTime(appConfig.transitionTime);
|
||||||
|
|
||||||
if (!appConfig.overlayHexRGB.isEmpty())
|
if (!appConfig.overlayHexRGB.isEmpty())
|
||||||
{
|
{
|
||||||
QRegularExpression hexRGBMatcher("^#([0-9A-Fa-f]{3}){1,2}$");
|
QRegularExpression hexRGBMatcher("^#([0-9A-Fa-f]{3}){1,2}$");
|
||||||
@@ -153,7 +158,11 @@ void ConfigureWindowFromSettings(MainWindow &w, const AppConfig &appConfig)
|
|||||||
std::unique_ptr<ImageSelector> GetSelectorForConfig(const PathEntry& path)
|
std::unique_ptr<ImageSelector> GetSelectorForConfig(const PathEntry& path)
|
||||||
{
|
{
|
||||||
std::unique_ptr<PathTraverser> pathTraverser;
|
std::unique_ptr<PathTraverser> pathTraverser;
|
||||||
if (!path.imageList.empty())
|
if (path.immich.enabled)
|
||||||
|
{
|
||||||
|
pathTraverser = std::unique_ptr<PathTraverser>(new ImmichPathTraverser(path.immich));
|
||||||
|
}
|
||||||
|
else if (!path.imageList.empty())
|
||||||
{
|
{
|
||||||
pathTraverser = std::unique_ptr<PathTraverser>(new ImageListPathTraverser(path.imageList));
|
pathTraverser = std::unique_ptr<PathTraverser>(new ImageListPathTraverser(path.imageList));
|
||||||
}
|
}
|
||||||
@@ -205,6 +214,11 @@ std::unique_ptr<ImageSelector> GetSelectorForApp(const AppConfig& appConfig)
|
|||||||
|
|
||||||
void ReloadConfigIfNeeded(AppConfig &appConfig, MainWindow &w, ImageSwitcher *switcher)
|
void ReloadConfigIfNeeded(AppConfig &appConfig, MainWindow &w, ImageSwitcher *switcher)
|
||||||
{
|
{
|
||||||
|
if(appConfig.configPath.empty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
QString jsonFile = getAppConfigFilePath(appConfig.configPath);
|
QString jsonFile = getAppConfigFilePath(appConfig.configPath);
|
||||||
QDir directory;
|
QDir directory;
|
||||||
if(!directory.exists(jsonFile))
|
if(!directory.exists(jsonFile))
|
||||||
@@ -251,11 +265,8 @@ int main(int argc, char *argv[])
|
|||||||
Log( "Rotation Time: ", appConfig.rotationSeconds );
|
Log( "Rotation Time: ", appConfig.rotationSeconds );
|
||||||
Log( "Overlay input: ", appConfig.overlay );
|
Log( "Overlay input: ", appConfig.overlay );
|
||||||
|
|
||||||
QNetworkAccessManager webCtrl;
|
|
||||||
|
|
||||||
MainWindow w;
|
MainWindow w;
|
||||||
ConfigureWindowFromSettings(w, appConfig);
|
ConfigureWindowFromSettings(w, appConfig);
|
||||||
w.setNetworkManager(&webCtrl);
|
|
||||||
w.show();
|
w.show();
|
||||||
|
|
||||||
std::unique_ptr<ImageSelector> selector = GetSelectorForApp(appConfig);
|
std::unique_ptr<ImageSelector> selector = GetSelectorForApp(appConfig);
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
#include <QGraphicsPixmapItem>
|
#include <QGraphicsPixmapItem>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
#include <QNetworkReply>
|
|
||||||
#include <sstream>
|
|
||||||
|
|
||||||
MainWindow::MainWindow(QWidget *parent) :
|
MainWindow::MainWindow(QWidget *parent) :
|
||||||
QMainWindow(parent),
|
QMainWindow(parent),
|
||||||
@@ -120,7 +118,8 @@ bool MainWindow::event(QEvent* event)
|
|||||||
void MainWindow::resizeEvent(QResizeEvent* event)
|
void MainWindow::resizeEvent(QResizeEvent* event)
|
||||||
{
|
{
|
||||||
QMainWindow::resizeEvent(event);
|
QMainWindow::resizeEvent(event);
|
||||||
updateImage(true);
|
this->findChild<QLabel*>("image")->clear();
|
||||||
|
updateImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::checkWindowSize()
|
void MainWindow::checkWindowSize()
|
||||||
@@ -133,7 +132,7 @@ void MainWindow::checkWindowSize()
|
|||||||
{
|
{
|
||||||
Log("Resizing Window", screenSize.width(), "," , screenSize.height() );
|
Log("Resizing Window", screenSize.width(), "," , screenSize.height() );
|
||||||
setFixedSize(screenSize);
|
setFixedSize(screenSize);
|
||||||
updateImage(true);
|
updateImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageAspectMatchesMonitor)
|
if (imageAspectMatchesMonitor)
|
||||||
@@ -161,49 +160,18 @@ void MainWindow::checkWindowSize()
|
|||||||
void MainWindow::setImage(const ImageDetails &imageDetails)
|
void MainWindow::setImage(const ImageDetails &imageDetails)
|
||||||
{
|
{
|
||||||
currentImage = imageDetails;
|
currentImage = imageDetails;
|
||||||
downloadedData.clear();
|
updateImage();
|
||||||
if (pendingReply)
|
|
||||||
{
|
|
||||||
pendingReply->abort();
|
|
||||||
}
|
|
||||||
updateImage(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::fileDownloaded(QNetworkReply* netReply)
|
void MainWindow::updateImage()
|
||||||
{
|
|
||||||
if (netReply == pendingReply)
|
|
||||||
{
|
|
||||||
pendingReply = nullptr;
|
|
||||||
QNetworkReply::NetworkError err = netReply->error();
|
|
||||||
if (err == QNetworkReply::NoError)
|
|
||||||
{
|
|
||||||
downloadedData = netReply->readAll();
|
|
||||||
netReply->deleteLater();
|
|
||||||
updateImage(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void MainWindow::updateImage(bool immediately)
|
|
||||||
{
|
{
|
||||||
checkWindowSize();
|
checkWindowSize();
|
||||||
if (currentImage.filename == "")
|
if (currentImage.filename == "")
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (currentImage.filename.find("https://") != std::string::npos && downloadedData.isNull())
|
|
||||||
{
|
|
||||||
if (pendingReply == nullptr)
|
|
||||||
{
|
|
||||||
QNetworkRequest request(QUrl(currentImage.filename.c_str()));
|
|
||||||
pendingReply = networkManager->get(request);
|
|
||||||
connect( networkManager, SIGNAL (finished(QNetworkReply*)), this, SLOT (fileDownloaded(QNetworkReply*)));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
QLabel *label = this->findChild<QLabel*>("image");
|
QLabel *label = this->findChild<QLabel*>("image");
|
||||||
const QPixmap* oldImage = label->pixmap();
|
const QPixmap* oldImage = label->pixmap();
|
||||||
if (oldImage != NULL && !immediately)
|
if (oldImage != NULL && transitionSeconds > 0)
|
||||||
{
|
{
|
||||||
QPalette palette;
|
QPalette palette;
|
||||||
palette.setBrush(QPalette::Background, *oldImage);
|
palette.setBrush(QPalette::Background, *oldImage);
|
||||||
@@ -211,18 +179,7 @@ void MainWindow::updateImage(bool immediately)
|
|||||||
}
|
}
|
||||||
|
|
||||||
QPixmap p;
|
QPixmap p;
|
||||||
if (!downloadedData.isNull())
|
|
||||||
{
|
|
||||||
p.loadFromData(downloadedData);
|
|
||||||
// BUG BUG have the selector update this?
|
|
||||||
currentImage.width = p.width();
|
|
||||||
currentImage.height = p.height();
|
|
||||||
currentImage.rotation = 0;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
p.load( currentImage.filename.c_str() );
|
p.load( currentImage.filename.c_str() );
|
||||||
}
|
|
||||||
|
|
||||||
Log("size:", p.width(), "x", p.height(), "(window:", width(), ",", height(), ")");
|
Log("size:", p.width(), "x", p.height(), "(window:", width(), ",", height(), ")");
|
||||||
|
|
||||||
@@ -256,13 +213,13 @@ void MainWindow::updateImage(bool immediately)
|
|||||||
|
|
||||||
label->setPixmap(background);
|
label->setPixmap(background);
|
||||||
|
|
||||||
if (oldImage != NULL && !immediately)
|
if (oldImage != NULL && transitionSeconds > 0)
|
||||||
{
|
{
|
||||||
auto effect = new QGraphicsOpacityEffect(label);
|
auto effect = new QGraphicsOpacityEffect(label);
|
||||||
effect->setOpacity(0.0);
|
effect->setOpacity(0.0);
|
||||||
label->setGraphicsEffect(effect);
|
label->setGraphicsEffect(effect);
|
||||||
QPropertyAnimation* animation = new QPropertyAnimation(effect, "opacity");
|
QPropertyAnimation* animation = new QPropertyAnimation(effect, "opacity");
|
||||||
animation->setDuration(1000);
|
animation->setDuration(transitionSeconds*1000);
|
||||||
animation->setStartValue(0);
|
animation->setStartValue(0);
|
||||||
animation->setEndValue(1);
|
animation->setEndValue(1);
|
||||||
animation->start(QAbstractAnimation::DeleteWhenStopped);
|
animation->start(QAbstractAnimation::DeleteWhenStopped);
|
||||||
@@ -298,8 +255,10 @@ void MainWindow::setOverlay(std::unique_ptr<Overlay> &o)
|
|||||||
QPixmap MainWindow::getBlurredBackground(const QPixmap& originalSize, const QPixmap& scaled)
|
QPixmap MainWindow::getBlurredBackground(const QPixmap& originalSize, const QPixmap& scaled)
|
||||||
{
|
{
|
||||||
if (currentImage.options.fitAspectAxisToWindow) {
|
if (currentImage.options.fitAspectAxisToWindow) {
|
||||||
// our scaled version will just fill the whole screen, us it directly
|
// our scaled version will just fill the whole screen, use it directly
|
||||||
return scaled.copy();
|
//Log("Using scaled image");
|
||||||
|
QRect rect((scaled.width() - width())/2, 0, width(), height());
|
||||||
|
return scaled.copy(rect);
|
||||||
} else if (scaled.width() < width()) {
|
} else if (scaled.width() < width()) {
|
||||||
QPixmap background = blur(originalSize.scaledToWidth(width(), Qt::SmoothTransformation));
|
QPixmap background = blur(originalSize.scaledToWidth(width(), Qt::SmoothTransformation));
|
||||||
QRect rect(0, (background.height() - height())/2, width(), height());
|
QRect rect(0, (background.height() - height())/2, width(), height());
|
||||||
@@ -323,18 +282,32 @@ QPixmap MainWindow::getScaledPixmap(const QPixmap& p)
|
|||||||
{
|
{
|
||||||
if (currentImage.options.fitAspectAxisToWindow)
|
if (currentImage.options.fitAspectAxisToWindow)
|
||||||
{
|
{
|
||||||
if (currentImage.aspect() == ImageAspect_Portrait)
|
bool stretchWidth = currentImage.aspect() == ImageAspect_Landscape;
|
||||||
|
bool stretchHeight = currentImage.aspect() == ImageAspect_Portrait;
|
||||||
|
// check the stretched image will naturally fill the screen for its aspect ratio
|
||||||
|
if (stretchHeight && (width() > ((double)height()/p.height())*p.width()))
|
||||||
|
{
|
||||||
|
// stretched via height won't fill the width, so stretch the other way
|
||||||
|
stretchHeight = false;
|
||||||
|
stretchWidth = true;
|
||||||
|
}
|
||||||
|
else if (stretchWidth && (height() > ((double)width()/p.width())*p.height()))
|
||||||
|
{
|
||||||
|
// stretched via width won't fill the width, so stretch the other way
|
||||||
|
stretchWidth = false;
|
||||||
|
stretchHeight = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stretchHeight)
|
||||||
{
|
{
|
||||||
// potrait mode, make height of image fit screen and crop top/bottom
|
// potrait mode, make height of image fit screen and crop top/bottom
|
||||||
QPixmap pTemp = p.scaledToHeight(height(), Qt::SmoothTransformation);
|
QPixmap pTemp = p.scaledToHeight(height(), Qt::SmoothTransformation);
|
||||||
return pTemp.copy(0,0,width(),height());
|
return pTemp.copy(0,0,width(),height());
|
||||||
}
|
}
|
||||||
else if (currentImage.aspect() == ImageAspect_Landscape)
|
else if (stretchWidth)
|
||||||
{
|
{
|
||||||
// landscape mode, make width of image fit screen and crop top/bottom
|
// landscape mode, make width of image fit screen and crop top/bottom
|
||||||
QPixmap pTemp = p.scaledToWidth(width(), Qt::SmoothTransformation);
|
QPixmap pTemp = p.scaledToWidth(width(), Qt::SmoothTransformation);
|
||||||
//int imageTempWidth = pTemp.width();
|
|
||||||
//int imageTempHeight = pTemp.height();
|
|
||||||
return pTemp.copy(0,0,width(),height());
|
return pTemp.copy(0,0,width(),height());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,6 +366,11 @@ void MainWindow::setOverlayHexRGB(QString overlayHexRGB)
|
|||||||
this->overlayHexRGB = overlayHexRGB;
|
this->overlayHexRGB = overlayHexRGB;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::setTransitionTime(unsigned int transitionSeconds)
|
||||||
|
{
|
||||||
|
this->transitionSeconds = transitionSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::warn(std::string text)
|
void MainWindow::warn(std::string text)
|
||||||
{
|
{
|
||||||
QLabel *label = this->findChild<QLabel*>("image");
|
QLabel *label = this->findChild<QLabel*>("image");
|
||||||
@@ -418,8 +396,3 @@ const ImageDisplayOptions &MainWindow::getBaseOptions()
|
|||||||
{
|
{
|
||||||
return baseImageOptions;
|
return baseImageOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::setNetworkManager(QNetworkAccessManager *networkManagerIn)
|
|
||||||
{
|
|
||||||
networkManager = networkManagerIn;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
#include <QNetworkAccessManager>
|
|
||||||
#include "imagestructs.h"
|
#include "imagestructs.h"
|
||||||
#include "imageselector.h"
|
#include "imageselector.h"
|
||||||
|
|
||||||
@@ -28,17 +27,15 @@ public:
|
|||||||
void setImage(const ImageDetails &imageDetails);
|
void setImage(const ImageDetails &imageDetails);
|
||||||
void setBlurRadius(unsigned int blurRadius);
|
void setBlurRadius(unsigned int blurRadius);
|
||||||
void setBackgroundOpacity(unsigned int opacity);
|
void setBackgroundOpacity(unsigned int opacity);
|
||||||
|
void setTransitionTime(unsigned int transitionSeconds);
|
||||||
void warn(std::string text);
|
void warn(std::string text);
|
||||||
void setOverlay(std::unique_ptr<Overlay> &overlay);
|
void setOverlay(std::unique_ptr<Overlay> &overlay);
|
||||||
void setBaseOptions(const ImageDisplayOptions &baseOptionsIn);
|
void setBaseOptions(const ImageDisplayOptions &baseOptionsIn);
|
||||||
const ImageDisplayOptions &getBaseOptions();
|
const ImageDisplayOptions &getBaseOptions();
|
||||||
void setImageSwitcher(ImageSwitcher *switcherIn);
|
void setImageSwitcher(ImageSwitcher *switcherIn);
|
||||||
void setNetworkManager(QNetworkAccessManager *networkManagerIn);
|
|
||||||
void setOverlayHexRGB(QString overlayHexRGB);
|
void setOverlayHexRGB(QString overlayHexRGB);
|
||||||
public slots:
|
public slots:
|
||||||
void checkWindowSize();
|
void checkWindowSize();
|
||||||
private slots:
|
|
||||||
void fileDownloaded(QNetworkReply* pReply);
|
|
||||||
private:
|
private:
|
||||||
Ui::MainWindow *ui;
|
Ui::MainWindow *ui;
|
||||||
|
|
||||||
@@ -47,18 +44,16 @@ private:
|
|||||||
ImageDisplayOptions baseImageOptions;
|
ImageDisplayOptions baseImageOptions;
|
||||||
bool imageAspectMatchesMonitor = false;
|
bool imageAspectMatchesMonitor = false;
|
||||||
ImageDetails currentImage;
|
ImageDetails currentImage;
|
||||||
QByteArray downloadedData;
|
|
||||||
QNetworkAccessManager *networkManager = nullptr;
|
|
||||||
QNetworkReply *pendingReply = nullptr;
|
|
||||||
QSize lastScreenSize = {0,0};
|
QSize lastScreenSize = {0,0};
|
||||||
QString overlayHexRGB = "#FFFF";
|
QString overlayHexRGB = "#FFFF";
|
||||||
|
unsigned int transitionSeconds = 1;
|
||||||
|
|
||||||
std::unique_ptr<Overlay> overlay;
|
std::unique_ptr<Overlay> overlay;
|
||||||
ImageSwitcher *switcher = nullptr;
|
ImageSwitcher *switcher = nullptr;
|
||||||
|
|
||||||
void drawText(QPixmap& image, int margin, int fontsize, QString text, int alignment);
|
void drawText(QPixmap& image, int margin, int fontsize, QString text, int alignment);
|
||||||
|
|
||||||
void updateImage(bool immediately);
|
void updateImage();
|
||||||
int getImageRotation();
|
int getImageRotation();
|
||||||
|
|
||||||
QPixmap getBlurredBackground(const QPixmap& originalSize, const QPixmap& scaled);
|
QPixmap getBlurredBackground(const QPixmap& originalSize, const QPixmap& scaled);
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
#include <QDirIterator>
|
#include <QDirIterator>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QDomDocument>
|
|
||||||
#include <QDomAttr>
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <stdlib.h> /* srand, rand */
|
#include <stdlib.h> /* srand, rand */
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <QNetworkAccessManager>
|
|
||||||
#include <QNetworkRequest>
|
|
||||||
#include <QNetworkReply>
|
|
||||||
#include "imageselector.h"
|
#include "imageselector.h"
|
||||||
|
|
||||||
static const QStringList supportedFormats={"jpg","jpeg","png","tif","tiff"};
|
static const QStringList supportedFormats={"jpg","jpeg","png","tif","tiff"};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
#
|
#
|
||||||
#-------------------------------------------------
|
#-------------------------------------------------
|
||||||
|
|
||||||
QT += core gui network xml
|
QT += core gui network
|
||||||
CONFIG += qt
|
CONFIG += qt
|
||||||
CONFIG += debug
|
CONFIG += debug
|
||||||
CONFIG += c++1z
|
CONFIG += c++1z
|
||||||
@@ -35,6 +35,8 @@ SOURCES += \
|
|||||||
mainwindow.cpp \
|
mainwindow.cpp \
|
||||||
imageswitcher.cpp \
|
imageswitcher.cpp \
|
||||||
pathtraverser.cpp \
|
pathtraverser.cpp \
|
||||||
|
immichpathtraverser.cpp \
|
||||||
|
immichclient.cpp \
|
||||||
overlay.cpp \
|
overlay.cpp \
|
||||||
imageselector.cpp \
|
imageselector.cpp \
|
||||||
appconfig.cpp \
|
appconfig.cpp \
|
||||||
@@ -45,6 +47,8 @@ HEADERS += \
|
|||||||
mainwindow.h \
|
mainwindow.h \
|
||||||
imageselector.h \
|
imageselector.h \
|
||||||
pathtraverser.h \
|
pathtraverser.h \
|
||||||
|
immichpathtraverser.h \
|
||||||
|
immichclient.h \
|
||||||
overlay.h \
|
overlay.h \
|
||||||
imageswitcher.h \
|
imageswitcher.h \
|
||||||
imagestructs.h \
|
imagestructs.h \
|
||||||
|
|||||||
Reference in New Issue
Block a user