Compare commits
1 Commits
master
...
99a436571b
| Author | SHA1 | Date | |
|---|---|---|---|
|
99a436571b
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,7 +2,3 @@
|
|||||||
|
|
||||||
# These are backup files generated by rustfmt
|
# These are backup files generated by rustfmt
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
# Flatpak stuff
|
|
||||||
/repo
|
|
||||||
*.flatpak
|
|
||||||
|
|||||||
52
Cargo.lock
generated
52
Cargo.lock
generated
@@ -2425,7 +2425,6 @@ dependencies = [
|
|||||||
"ravif",
|
"ravif",
|
||||||
"rayon",
|
"rayon",
|
||||||
"rgb",
|
"rgb",
|
||||||
"serde",
|
|
||||||
"tiff",
|
"tiff",
|
||||||
"zune-core",
|
"zune-core",
|
||||||
"zune-jpeg",
|
"zune-jpeg",
|
||||||
@@ -2461,9 +2460,6 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"blurhash",
|
"blurhash",
|
||||||
"clap",
|
"clap",
|
||||||
"either",
|
|
||||||
"futures",
|
|
||||||
"image",
|
|
||||||
"immich-sdk",
|
"immich-sdk",
|
||||||
"kameo",
|
"kameo",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2471,24 +2467,19 @@ dependencies = [
|
|||||||
"slint",
|
"slint",
|
||||||
"slint-build",
|
"slint-build",
|
||||||
"thumbhash",
|
"thumbhash",
|
||||||
"tikv-jemallocator",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml 1.1.2+spec-1.1.0",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"xdg",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "immich-sdk"
|
name = "immich-sdk"
|
||||||
version = "1.137.0"
|
version = "1.137.0"
|
||||||
source = "git+https://git.nubo.sh/hulthe/immich-sdk.git?rev=c0bde4f8bd50d2861548666297f40bed3b85b865#c0bde4f8bd50d2861548666297f40bed3b85b865"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"image",
|
"image",
|
||||||
"mime",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -4801,7 +4792,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tar",
|
"tar",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5293,26 +5284,6 @@ dependencies = [
|
|||||||
"zune-jpeg",
|
"zune-jpeg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tikv-jemalloc-sys"
|
|
||||||
version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tikv-jemallocator"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"tikv-jemalloc-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tiny-skia"
|
name = "tiny-skia"
|
||||||
version = "0.11.4"
|
version = "0.11.4"
|
||||||
@@ -5457,21 +5428,6 @@ dependencies = [
|
|||||||
"winnow 0.7.15",
|
"winnow 0.7.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml"
|
|
||||||
version = "1.1.2+spec-1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
|
||||||
dependencies = [
|
|
||||||
"indexmap",
|
|
||||||
"serde_core",
|
|
||||||
"serde_spanned",
|
|
||||||
"toml_datetime 1.1.1+spec-1.1.0",
|
|
||||||
"toml_parser",
|
|
||||||
"toml_writer",
|
|
||||||
"winnow 1.0.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "0.7.5+spec-1.1.0"
|
version = "0.7.5+spec-1.1.0"
|
||||||
@@ -6842,12 +6798,6 @@ version = "0.3.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
|
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "xdg"
|
|
||||||
version = "3.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xkbcommon"
|
name = "xkbcommon"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
12
Cargo.toml
12
Cargo.toml
@@ -8,9 +8,7 @@ anyhow = "1.0.102"
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
blurhash = "0.2.3"
|
blurhash = "0.2.3"
|
||||||
clap = { version = "4.6.0", features = ["derive", "env"] }
|
clap = { version = "4.6.0", features = ["derive", "env"] }
|
||||||
either = "1.15.0"
|
immich-sdk.path = "../immich-sdk/"
|
||||||
futures = "0.3.32"
|
|
||||||
image = { version = "0.25.10", default-features = false, features = ["serde", "webp"] }
|
|
||||||
kameo = "0.19.2"
|
kameo = "0.19.2"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
@@ -18,14 +16,6 @@ thumbhash = "0.1.0"
|
|||||||
tokio = { version = "1.51.0", features = ["full"] }
|
tokio = { version = "1.51.0", features = ["full"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
xdg = "3.0.0"
|
|
||||||
tikv-jemallocator = "0.6"
|
|
||||||
toml = "1.1.2"
|
|
||||||
|
|
||||||
[dependencies.immich-sdk]
|
|
||||||
#path = "../immich-sdk"
|
|
||||||
git = "https://git.nubo.sh/hulthe/immich-sdk.git"
|
|
||||||
rev = "c0bde4f8bd50d2861548666297f40bed3b85b865"
|
|
||||||
|
|
||||||
[dependencies.slint]
|
[dependencies.slint]
|
||||||
version = "1.16.1"
|
version = "1.16.1"
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -1,29 +0,0 @@
|
|||||||
# immich-rs
|
|
||||||
Desktop client for https://immich.app/
|
|
||||||
|
|
||||||
## Building (Flatpak)
|
|
||||||
|
|
||||||
1) Install flatpak
|
|
||||||
|
|
||||||
2) Install runtime and SDK
|
|
||||||
|
|
||||||
```
|
|
||||||
flatpak --user install flathub \
|
|
||||||
org.freedesktop.Platform//25.08 \
|
|
||||||
org.freedesktop.Sdk//25.08 \
|
|
||||||
org.freedesktop.Sdk.Extension.rust-stable//25.08
|
|
||||||
```
|
|
||||||
|
|
||||||
3) Build flatpak
|
|
||||||
|
|
||||||
```
|
|
||||||
flatpak-builder --user --force-clean --repo=repo build-dir sh.nubo.immich-rs.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `--disable-rofiles-fuse` if running in an environment without FUSE.
|
|
||||||
|
|
||||||
4) Export flatpak bundle
|
|
||||||
|
|
||||||
```
|
|
||||||
flatpak build-bundle repo sh.nubo.immich-rs.flatpak sh.nubo.immich-rs
|
|
||||||
```
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg width="1.375em" height="1.375em" viewBox="0 0 24 24" class="shrink-0 -scale-x-100 svelte-ztbs85" stroke="transparent" stroke-width="2" role="img" aria-hidden="true"><!----><!----><path d="M6,19L9,15.14L11.14,17.72L14.14,13.86L18,19H6M6,4H11V12L8.5,10.5L6,12M18,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" fill="currentColor"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 374 B |
@@ -1,44 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
class="text-primary svelte-ztbs85"
|
|
||||||
stroke="transparent"
|
|
||||||
stroke-width="2"
|
|
||||||
role="img"
|
|
||||||
version="1.1"
|
|
||||||
id="svg1"
|
|
||||||
sodipodi:docname="checked.svg"
|
|
||||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<defs
|
|
||||||
id="defs1" />
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview1"
|
|
||||||
pagecolor="#505050"
|
|
||||||
bordercolor="#eeeeee"
|
|
||||||
borderopacity="1"
|
|
||||||
inkscape:showpageshadow="0"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
inkscape:deskcolor="#505050"
|
|
||||||
inkscape:zoom="13.470055"
|
|
||||||
inkscape:cx="12.583468"
|
|
||||||
inkscape:cy="5.8277418"
|
|
||||||
inkscape:window-width="1555"
|
|
||||||
inkscape:window-height="1000"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="0"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="svg1" />
|
|
||||||
<!---->
|
|
||||||
<!---->
|
|
||||||
<path
|
|
||||||
d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z"
|
|
||||||
fill="currentColor"
|
|
||||||
id="path1" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
<svg version="1.1" id="Flower" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 792" style="enable-background:new 0 0 792 792;" xml:space="preserve">
|
|
||||||
<style type="text/css">
|
|
||||||
.st0{fill:#FA2921;}
|
|
||||||
.st1{fill:#ED79B5;}
|
|
||||||
.st2{fill:#FFB400;}
|
|
||||||
.st3{fill:#1E83F7;}
|
|
||||||
.st4{fill:#18C249;}
|
|
||||||
</style>
|
|
||||||
<g id="Flower_00000077325900055813483940000000694823054982625702_">
|
|
||||||
<path class="st0" d="M375.48,267.63c38.64,34.21,69.78,70.87,89.82,105.42c34.42-61.56,57.42-134.71,57.71-181.3 c0-0.33,0-0.63,0-0.91c0-68.94-68.77-95.77-128.01-95.77s-128.01,26.83-128.01,95.77c0,0.94,0,2.2,0,3.72 C300.01,209.24,339.15,235.47,375.48,267.63z"/>
|
|
||||||
<path class="st1" d="M164.7,455.63c24.15-26.87,61.2-55.99,103.01-80.61c44.48-26.18,88.97-44.47,128.02-52.84 c-47.91-51.76-110.37-96.24-154.6-110.91c-0.31-0.1-0.6-0.19-0.86-0.28c-65.57-21.3-112.34,35.81-130.64,92.15 c-18.3,56.34-14.04,130.04,51.53,151.34C162.05,454.77,163.25,455.16,164.7,455.63z"/>
|
|
||||||
<path class="st2" d="M681.07,302.19c-18.3-56.34-65.07-113.45-130.64-92.15c-0.9,0.29-2.1,0.68-3.54,1.15 c-3.75,35.93-16.6,81.27-35.96,125.76c-20.59,47.32-45.84,88.27-72.51,118c69.18,13.72,145.86,12.98,190.26-1.14 c0.31-0.1,0.6-0.2,0.86-0.28C695.11,432.22,699.37,358.52,681.07,302.19z"/>
|
|
||||||
<path class="st3" d="M336.54,510.71c-11.15-50.39-14.8-98.36-10.7-138.08c-64.03,29.57-125.63,75.23-153.26,112.76 c-0.19,0.26-0.37,0.51-0.53,0.73c-40.52,55.78-0.66,117.91,47.27,152.72c47.92,34.82,119.33,53.54,159.86-2.24 c0.56-0.76,1.3-1.78,2.19-3.01C363.28,602.32,347.02,558.08,336.54,510.71z"/>
|
|
||||||
<path class="st4" d="M617.57,482.52c-35.33,7.54-82.42,9.33-130.72,4.66c-51.37-4.96-98.11-16.32-134.63-32.5 c8.33,70.03,32.73,142.73,59.88,180.6c0.19,0.26,0.37,0.51,0.53,0.73c40.52,55.78,111.93,37.06,159.86,2.24 c47.92-34.82,87.79-96.95,47.27-152.72C619.2,484.77,618.46,483.75,617.57,482.52z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,7 +0,0 @@
|
|||||||
[Desktop Entry]
|
|
||||||
Name=immich-rs
|
|
||||||
Comment=Immich desktop client
|
|
||||||
Exec=immich-rs
|
|
||||||
Icon=sh.nubo.immich-rs
|
|
||||||
Type=Application
|
|
||||||
Categories=Network;Photography;AudioVideo;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg width="1.375em" height="1.375em" viewBox="0 0 24 24" class="shrink-0 svelte-ztbs85" stroke="transparent" stroke-width="2" role="img" aria-hidden="true"><!----><!----><path d="M22,16V4A2,2 0 0,0 20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16M11,12L13.03,14.71L16,11L20,16H8M2,6V20A2,2 0 0,0 4,22H18V20H4V6" fill="currentColor"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 356 B |
@@ -1 +0,0 @@
|
|||||||
<svg width="1.375em" height="1.375em" viewBox="0 0 24 24" class="shrink-0 svelte-ztbs85" stroke="transparent" stroke-width="2" role="img" aria-hidden="true"><!----><!----><path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z" fill="currentColor"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 473 B |
@@ -1,47 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
class="text-primary svelte-ztbs85"
|
|
||||||
stroke="transparent"
|
|
||||||
stroke-width="2"
|
|
||||||
role="img"
|
|
||||||
version="1.1"
|
|
||||||
id="svg1"
|
|
||||||
sodipodi:docname="unchecked.svg"
|
|
||||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<defs
|
|
||||||
id="defs1" />
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview1"
|
|
||||||
pagecolor="#505050"
|
|
||||||
bordercolor="#eeeeee"
|
|
||||||
borderopacity="1"
|
|
||||||
inkscape:showpageshadow="0"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
inkscape:deskcolor="#505050"
|
|
||||||
inkscape:zoom="11.92186"
|
|
||||||
inkscape:cx="32.922716"
|
|
||||||
inkscape:cy="21.682859"
|
|
||||||
inkscape:window-width="1555"
|
|
||||||
inkscape:window-height="1000"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="0"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="svg1" />
|
|
||||||
<!---->
|
|
||||||
<!---->
|
|
||||||
<ellipse
|
|
||||||
style="fill:none;stroke:#000000;stroke-width:1.91177;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
|
||||||
id="path2"
|
|
||||||
cx="12"
|
|
||||||
cy="11.999998"
|
|
||||||
rx="9.0441151"
|
|
||||||
ry="9.0441122" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,41 +0,0 @@
|
|||||||
app-id: sh.nubo.immich-rs
|
|
||||||
runtime: org.freedesktop.Platform
|
|
||||||
runtime-version: '25.08'
|
|
||||||
sdk: org.freedesktop.Sdk
|
|
||||||
sdk-extensions:
|
|
||||||
- org.freedesktop.Sdk.Extension.rust-stable
|
|
||||||
|
|
||||||
build-options:
|
|
||||||
append-path: /usr/lib/sdk/rust-stable/bin
|
|
||||||
build-args:
|
|
||||||
- --share=network
|
|
||||||
|
|
||||||
command: immich-rs
|
|
||||||
|
|
||||||
finish-args:
|
|
||||||
# Core Wayland access
|
|
||||||
- --socket=wayland
|
|
||||||
# GPU acceleration (DRI)
|
|
||||||
- --device=dri
|
|
||||||
# Access to XDG dirs
|
|
||||||
- --filesystem=xdg-config/immich-rs:create
|
|
||||||
- --filesystem=xdg-data/immich-rs:create
|
|
||||||
- --filesystem=xdg-cache/immich-rs:create
|
|
||||||
# Talk to portal for file dialogs, etc.
|
|
||||||
- --talk-name=org.freedesktop.portal.Desktop
|
|
||||||
# Network access
|
|
||||||
- --share=network
|
|
||||||
|
|
||||||
modules:
|
|
||||||
- name: immich-rs
|
|
||||||
buildsystem: simple
|
|
||||||
build-commands:
|
|
||||||
- cargo build --release --locked
|
|
||||||
- install -Dm755 ./target/release/immich-rs -t /app/bin/
|
|
||||||
- install -Dm644 ./assets/immich-rs.desktop /app/share/applications/sh.nubo.immich-rs.desktop
|
|
||||||
- install -Dm644 ./assets/immich-logo.svg /app/share/icons/hicolor/scalable/apps/sh.nubo.immich-rs.svg
|
|
||||||
# - install -Dm644 ./assets/sh.nubo.immich-rs.metainfo.xml -t /app/share/metainfo/
|
|
||||||
sources:
|
|
||||||
- type: dir
|
|
||||||
path: .
|
|
||||||
|
|
||||||
253
src/api.rs
253
src/api.rs
@@ -1,157 +1,44 @@
|
|||||||
use std::{
|
use std::{collections::HashMap, iter::repeat, sync::Arc};
|
||||||
collections::HashMap,
|
|
||||||
io::Cursor,
|
|
||||||
iter::repeat,
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
};
|
|
||||||
|
|
||||||
use anyhow::{Context as _, anyhow};
|
use anyhow::{Context as _, anyhow};
|
||||||
use image::{
|
use immich_sdk::{AssetId, AssetVisibility};
|
||||||
DynamicImage, EncodableLayout,
|
use kameo::{
|
||||||
codecs::webp::{WebPDecoder, WebPEncoder},
|
Actor,
|
||||||
|
prelude::{Context, Message},
|
||||||
};
|
};
|
||||||
use immich_sdk::{AlbumResponse, AssetId, AssetVisibility};
|
|
||||||
use slint::{Rgba8Pixel, SharedPixelBuffer};
|
use slint::{Rgba8Pixel, SharedPixelBuffer};
|
||||||
|
|
||||||
use crate::{
|
use crate::thumbhash::thumbhashes_to_pixels;
|
||||||
cachemap::{AsyncFnFetcher, CacheMap, Fetcher},
|
|
||||||
thumbhash::thumbhashes_to_pixels,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub type TimeBucketKey = String;
|
pub type TimeBucketKey = String;
|
||||||
|
|
||||||
|
#[derive(Actor)]
|
||||||
pub struct Api {
|
pub struct Api {
|
||||||
client: immich_sdk::Client,
|
client: immich_sdk::Client,
|
||||||
buckets: Mutex<HashMap<TimeBucketKey, Arc<TimeBucket>>>,
|
buckets: HashMap<TimeBucketKey, Arc<TimeBucket>>,
|
||||||
thumbnails: Mutex<CacheMap<AssetId, Arc<AssetThumbnail>>>,
|
thumbnails: HashMap<AssetId, Arc<AssetThumbnail>>, // TODO
|
||||||
assets: Mutex<CacheMap<AssetId, Arc<AssetThumbnail>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_thumbnail(_id: &AssetId, thumbnail: &Arc<AssetThumbnail>) -> Vec<u8> {
|
|
||||||
let image: &SharedPixelBuffer<Rgba8Pixel> = &thumbnail.thumbnail;
|
|
||||||
|
|
||||||
let mut webp = vec![];
|
|
||||||
WebPEncoder::new_lossless(&mut webp)
|
|
||||||
.encode(
|
|
||||||
image.as_bytes(),
|
|
||||||
image.width(),
|
|
||||||
image.height(),
|
|
||||||
image::ExtendedColorType::Rgba8,
|
|
||||||
)
|
|
||||||
.expect("width and height matches image.as_bytes().len()");
|
|
||||||
|
|
||||||
webp
|
|
||||||
}
|
|
||||||
fn deserialize_thumbnail(&id: &AssetId, bytes: &[u8]) -> anyhow::Result<Arc<AssetThumbnail>> {
|
|
||||||
let image = WebPDecoder::new(Cursor::new(bytes))
|
|
||||||
.and_then(DynamicImage::from_decoder)
|
|
||||||
.context("Failed to decode image")?
|
|
||||||
.to_rgba8();
|
|
||||||
|
|
||||||
let image = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(
|
|
||||||
image.as_bytes(),
|
|
||||||
image.width(),
|
|
||||||
image.height(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Arc::new(AssetThumbnail {
|
|
||||||
id,
|
|
||||||
thumbnail: image,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Api {
|
impl Api {
|
||||||
pub fn new(client: immich_sdk::Client) -> Arc<Self> {
|
pub fn new(client: immich_sdk::Client) -> Self {
|
||||||
let client_ = client.clone();
|
Self {
|
||||||
let asset_fetcher = AsyncFnFetcher::new(move |asset_id: &AssetId| {
|
|
||||||
let client = client_.clone();
|
|
||||||
let asset_id = asset_id.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let asset = client
|
|
||||||
.assets()
|
|
||||||
.download(asset_id)
|
|
||||||
.edited()
|
|
||||||
.execute()
|
|
||||||
.await
|
|
||||||
.context(anyhow!("Failed to get asset {asset_id}"))?;
|
|
||||||
|
|
||||||
let asset = asset
|
|
||||||
.decode()
|
|
||||||
.context(anyhow!("Failed to decode asset {asset_id}"))?
|
|
||||||
.into_rgba8();
|
|
||||||
|
|
||||||
let pixel_buffer = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(
|
|
||||||
&asset,
|
|
||||||
asset.width(),
|
|
||||||
asset.height(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Arc::new(AssetThumbnail {
|
|
||||||
id: asset_id,
|
|
||||||
thumbnail: pixel_buffer,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let asset_map = CacheMap::new(
|
|
||||||
Arc::new(asset_fetcher) as Arc<dyn Fetcher<Arc<AssetThumbnail>, Key = AssetId>>,
|
|
||||||
"assets",
|
|
||||||
serialize_thumbnail,
|
|
||||||
deserialize_thumbnail,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let client_ = client.clone();
|
|
||||||
let thumbnail_fetcher = AsyncFnFetcher::new(move |asset_id: &AssetId| {
|
|
||||||
let client = client_.clone();
|
|
||||||
let asset_id = asset_id.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let asset = client
|
|
||||||
.assets()
|
|
||||||
.thumbnail(asset_id)
|
|
||||||
.size(immich_sdk::AssetMediaSize::Thumbnail)
|
|
||||||
.execute()
|
|
||||||
.await
|
|
||||||
.context(anyhow!("Failed to get asset thumbnail for {asset_id}"))?;
|
|
||||||
|
|
||||||
let thumbnail = asset
|
|
||||||
.decode()
|
|
||||||
.context(anyhow!("Failed to decode asset thumbnail for {asset_id}"))?
|
|
||||||
.into_rgba8();
|
|
||||||
|
|
||||||
let pixel_buffer = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(
|
|
||||||
&thumbnail,
|
|
||||||
thumbnail.width(),
|
|
||||||
thumbnail.height(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Arc::new(AssetThumbnail {
|
|
||||||
id: asset_id,
|
|
||||||
thumbnail: pixel_buffer,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let thumbnail_map = CacheMap::new(
|
|
||||||
Arc::new(thumbnail_fetcher) as Arc<dyn Fetcher<Arc<AssetThumbnail>, Key = AssetId>>,
|
|
||||||
"thumbnails",
|
|
||||||
serialize_thumbnail,
|
|
||||||
deserialize_thumbnail,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
Arc::new(Self {
|
|
||||||
client,
|
client,
|
||||||
buckets: Default::default(),
|
buckets: Default::default(),
|
||||||
thumbnails: Mutex::new(thumbnail_map),
|
thumbnails: Default::default(),
|
||||||
assets: Mutex::new(asset_map),
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GetTimeBuckets;
|
||||||
pub struct TimeBucketRef {
|
pub struct TimeBucketRef {
|
||||||
pub key: TimeBucketKey,
|
pub key: TimeBucketKey,
|
||||||
pub count: usize,
|
pub count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GetTimeBucket {
|
||||||
|
pub time_bucket: TimeBucketKey,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct TimeBucket {
|
pub struct TimeBucket {
|
||||||
pub key: TimeBucketKey,
|
pub key: TimeBucketKey,
|
||||||
pub entries: Arc<[TimeBucketEntry]>,
|
pub entries: Arc<[TimeBucketEntry]>,
|
||||||
@@ -167,20 +54,30 @@ pub struct TimeBucketEntry {
|
|||||||
pub visibility: AssetVisibility,
|
pub visibility: AssetVisibility,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GetAssetThumbnail {
|
||||||
|
pub id: AssetId,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AssetThumbnail {
|
pub struct AssetThumbnail {
|
||||||
pub id: AssetId,
|
pub id: AssetId,
|
||||||
pub thumbnail: SharedPixelBuffer<Rgba8Pixel>,
|
pub thumbnail: SharedPixelBuffer<Rgba8Pixel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Api {
|
impl Message<GetTimeBuckets> for Api {
|
||||||
pub async fn get_time_buckets(&self) -> anyhow::Result<Vec<TimeBucketRef>> {
|
type Reply = anyhow::Result<Arc<[TimeBucketRef]>>;
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
&mut self,
|
||||||
|
_msg: GetTimeBuckets,
|
||||||
|
_ctx: &mut Context<Self, Self::Reply>,
|
||||||
|
) -> Self::Reply {
|
||||||
let buckets = self
|
let buckets = self
|
||||||
.client
|
.client
|
||||||
.timeline()
|
.timeline()
|
||||||
.buckets()
|
.buckets()
|
||||||
.execute()
|
.execute()
|
||||||
.await
|
.await
|
||||||
.context(anyhow!("Failed to fetch list of time buckets"))
|
.context(anyhow!("Failed to fetch list of time buckets",))
|
||||||
.inspect_err(|e| {
|
.inspect_err(|e| {
|
||||||
tracing::error!("{e:?}");
|
tracing::error!("{e:?}");
|
||||||
})?;
|
})?;
|
||||||
@@ -193,22 +90,30 @@ impl Api {
|
|||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_time_bucket(
|
impl Message<GetTimeBucket> for Api {
|
||||||
&self,
|
type Reply = anyhow::Result<Arc<TimeBucket>>;
|
||||||
time_bucket: TimeBucketKey,
|
|
||||||
) -> anyhow::Result<Arc<TimeBucket>> {
|
async fn handle(
|
||||||
if let Some(time_bucket) = self.buckets.lock().unwrap().get(&time_bucket).cloned() {
|
&mut self,
|
||||||
|
msg: GetTimeBucket,
|
||||||
|
_ctx: &mut Context<Self, Self::Reply>,
|
||||||
|
) -> Self::Reply {
|
||||||
|
if let Some(time_bucket) = self.buckets.get(&msg.time_bucket).cloned() {
|
||||||
return Ok(time_bucket);
|
return Ok(time_bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
let bucket = self
|
let bucket = self
|
||||||
.client
|
.client
|
||||||
.timeline()
|
.timeline()
|
||||||
.bucket(&time_bucket)
|
.bucket(&msg.time_bucket)
|
||||||
.execute()
|
.execute()
|
||||||
.await
|
.await
|
||||||
.context(anyhow!("Failed to fetch time bucket {:?}", &time_bucket))
|
.context(anyhow!(
|
||||||
|
"Failed to fetch time bucket {:?}",
|
||||||
|
&msg.time_bucket
|
||||||
|
))
|
||||||
.inspect_err(|e| {
|
.inspect_err(|e| {
|
||||||
tracing::error!("{e:?}");
|
tracing::error!("{e:?}");
|
||||||
})?;
|
})?;
|
||||||
@@ -239,47 +144,55 @@ impl Api {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let bucket = Arc::new(TimeBucket {
|
let bucket = Arc::new(TimeBucket {
|
||||||
key: time_bucket,
|
key: msg.time_bucket,
|
||||||
entries,
|
entries,
|
||||||
});
|
});
|
||||||
|
|
||||||
self.buckets
|
self.buckets.insert(bucket.key.clone(), bucket.clone());
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.insert(bucket.key.clone(), bucket.clone());
|
|
||||||
|
|
||||||
Ok(bucket)
|
Ok(bucket)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_asset_thumbnail(
|
impl Message<GetAssetThumbnail> for Api {
|
||||||
&self,
|
type Reply = anyhow::Result<Arc<AssetThumbnail>>;
|
||||||
asset_id: AssetId,
|
|
||||||
) -> anyhow::Result<Arc<AssetThumbnail>> {
|
|
||||||
let fetch = self.thumbnails.lock().unwrap().get(asset_id);
|
|
||||||
fetch
|
|
||||||
.await
|
|
||||||
.context(anyhow!("Failed to fetch thumbnail for {asset_id:?}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_asset(&self, asset_id: AssetId) -> anyhow::Result<Arc<AssetThumbnail>> {
|
async fn handle(
|
||||||
let fetch = self.assets.lock().unwrap().get(asset_id);
|
&mut self,
|
||||||
fetch
|
msg: GetAssetThumbnail,
|
||||||
.await
|
_ctx: &mut Context<Self, Self::Reply>,
|
||||||
.context(anyhow!("Failed to fetch asset {asset_id:?}"))
|
) -> Self::Reply {
|
||||||
}
|
if let Some(thumbnail) = self.thumbnails.get(&msg.id).cloned() {
|
||||||
|
return Ok(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_album_list(&self) -> anyhow::Result<Vec<AlbumResponse>> {
|
let response = self
|
||||||
let albums = self
|
|
||||||
.client
|
.client
|
||||||
.albums()
|
.assets()
|
||||||
.list()
|
.thumbnail(msg.id)
|
||||||
|
.size(immich_sdk::AssetMediaSize::Thumbnail)
|
||||||
.execute()
|
.execute()
|
||||||
.await
|
.await
|
||||||
.context(anyhow!("Failed to fetch album list"))
|
.context(anyhow!("Failed to get asset thumbnail for {}", msg.id))?;
|
||||||
.inspect_err(|e| {
|
|
||||||
tracing::error!("{e:?}");
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(albums)
|
let thumbnail = response
|
||||||
|
.decode()
|
||||||
|
.context(anyhow!("Failed to decode asset thumbnail for {}", msg.id))?
|
||||||
|
.into_rgba8();
|
||||||
|
|
||||||
|
let pixel_buffer = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(
|
||||||
|
&thumbnail,
|
||||||
|
thumbnail.width(),
|
||||||
|
thumbnail.height(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let thumbnail = Arc::new(AssetThumbnail {
|
||||||
|
id: msg.id,
|
||||||
|
thumbnail: pixel_buffer,
|
||||||
|
});
|
||||||
|
|
||||||
|
self.thumbnails.insert(msg.id, Arc::clone(&thumbnail));
|
||||||
|
|
||||||
|
Ok(thumbnail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
191
src/cachemap.rs
191
src/cachemap.rs
@@ -1,191 +0,0 @@
|
|||||||
use std::{
|
|
||||||
collections::{HashMap, hash_map::Entry},
|
|
||||||
fmt::Debug,
|
|
||||||
future::ready,
|
|
||||||
hash::Hash,
|
|
||||||
marker::PhantomData,
|
|
||||||
path::PathBuf,
|
|
||||||
pin::Pin,
|
|
||||||
str::FromStr,
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use anyhow::{Context, anyhow};
|
|
||||||
use either::Either;
|
|
||||||
use futures::{FutureExt, future::WeakShared};
|
|
||||||
use tokio::{fs, task::JoinHandle};
|
|
||||||
|
|
||||||
use crate::xdg::BASE_DIRECTORIES;
|
|
||||||
|
|
||||||
pub trait Fetcher<V>: Send + Sync + 'static {
|
|
||||||
type Key: ToString + FromStr;
|
|
||||||
|
|
||||||
fn fetch(
|
|
||||||
&self,
|
|
||||||
key: &Self::Key,
|
|
||||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<V>> + Send + Sync>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AsyncFnFetcher<K, V, F> {
|
|
||||||
f: F,
|
|
||||||
_phantom: PhantomData<(K, V)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<K, V, F> AsyncFnFetcher<K, V, F>
|
|
||||||
where
|
|
||||||
K: ToString + FromStr + Send + 'static,
|
|
||||||
F: Fn(&K) -> JoinHandle<anyhow::Result<V>> + Send + 'static,
|
|
||||||
V: Send + 'static,
|
|
||||||
{
|
|
||||||
pub fn new(f: F) -> Self {
|
|
||||||
Self {
|
|
||||||
f,
|
|
||||||
_phantom: PhantomData,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<K, V, F> Fetcher<V> for AsyncFnFetcher<K, V, F>
|
|
||||||
where
|
|
||||||
K: ToString + FromStr + Send + Sync + 'static,
|
|
||||||
F: Fn(&K) -> JoinHandle<anyhow::Result<V>> + Send + Sync + 'static,
|
|
||||||
V: Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
type Key = K;
|
|
||||||
|
|
||||||
fn fetch(
|
|
||||||
&self,
|
|
||||||
key: &Self::Key,
|
|
||||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<V>> + Send + Sync>> {
|
|
||||||
let handle = (self.f)(key);
|
|
||||||
Box::pin(async move { handle.await.context("Fetch task panicked")? })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type FetchJob<V> = Pin<Box<dyn Future<Output = Option<V>> + Send + Sync>>;
|
|
||||||
|
|
||||||
pub struct CacheMap<K, V> {
|
|
||||||
fetcher: Arc<dyn Fetcher<V, Key = K>>,
|
|
||||||
cache_dir: PathBuf,
|
|
||||||
serialize: fn(&K, &V) -> Vec<u8>,
|
|
||||||
deserialize: fn(&K, &[u8]) -> anyhow::Result<V>,
|
|
||||||
|
|
||||||
/// Cache of ongoing [`FetchJob`]s.
|
|
||||||
///
|
|
||||||
/// If [`CacheMap::get`] is called while a fetch job is ongoing, [`WeakShared::upgrade`]
|
|
||||||
/// will succeed, and the [`FetchJob`] can be cloned.
|
|
||||||
fetch_jobs: HashMap<K, WeakShared<FetchJob<V>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<K, V> CacheMap<K, V>
|
|
||||||
where
|
|
||||||
K: Debug + FromStr + ToString + Eq + Hash + Clone + Send + Sync + 'static,
|
|
||||||
V: Clone + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
pub fn new(
|
|
||||||
fetcher: Arc<dyn Fetcher<V, Key = K> + 'static>,
|
|
||||||
cache_name: &str,
|
|
||||||
serialize: fn(&K, &V) -> Vec<u8>,
|
|
||||||
deserialize: fn(&K, &[u8]) -> anyhow::Result<V>,
|
|
||||||
) -> anyhow::Result<Self> {
|
|
||||||
let data_dir = BASE_DIRECTORIES
|
|
||||||
.create_cache_directory(cache_name)
|
|
||||||
.context(anyhow!(
|
|
||||||
"Failed to create XDG data folder for {cache_name:?}"
|
|
||||||
))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
fetcher,
|
|
||||||
cache_dir: data_dir,
|
|
||||||
serialize,
|
|
||||||
deserialize,
|
|
||||||
fetch_jobs: HashMap::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch_from_cache(
|
|
||||||
&self,
|
|
||||||
key: &K,
|
|
||||||
) -> impl Future<Output = anyhow::Result<V>> + Send + Sync + use<K, V> {
|
|
||||||
let key = key.clone();
|
|
||||||
let key_str = key.to_string();
|
|
||||||
let path = self.cache_dir.join(key_str);
|
|
||||||
let deserialize = self.deserialize;
|
|
||||||
Box::pin(async move {
|
|
||||||
let bytes = fs::read(&path)
|
|
||||||
.await
|
|
||||||
.context(anyhow!("Failed to read {path:?}"))?;
|
|
||||||
deserialize(&key, &bytes).context(anyhow!("Failed to deserialize value at {path:?}"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&mut self, key: K) -> impl Future<Output = Option<V>> + use<K, V> {
|
|
||||||
// FIXME: creating this future here because lifetimes.
|
|
||||||
let fetch_from_cache = self.fetch_from_cache(&key);
|
|
||||||
|
|
||||||
let entry = match self.fetch_jobs.entry(key.clone()) {
|
|
||||||
Entry::Vacant(entry) => entry,
|
|
||||||
Entry::Occupied(entry) => {
|
|
||||||
if let Some(fetching) = entry.get().upgrade() {
|
|
||||||
return match fetching.clone().now_or_never() {
|
|
||||||
// Value fetched
|
|
||||||
Some(Some(value)) => {
|
|
||||||
entry.remove();
|
|
||||||
Either::Left(ready(Some(value)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Failed to fetch
|
|
||||||
Some(None) => {
|
|
||||||
entry.remove();
|
|
||||||
Either::Left(ready(None))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Still pending
|
|
||||||
None => Either::Right(fetching.clone()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.remove();
|
|
||||||
let Entry::Vacant(entry) = self.fetch_jobs.entry(key.clone()) else {
|
|
||||||
unreachable!()
|
|
||||||
};
|
|
||||||
entry
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let fetcher = self.fetcher.clone();
|
|
||||||
|
|
||||||
let serialize = self.serialize;
|
|
||||||
let file_path = self.cache_dir.join(key.to_string());
|
|
||||||
let fetching = Box::pin(async move {
|
|
||||||
if let Ok(value) = fetch_from_cache.await.inspect_err(|e| {
|
|
||||||
tracing::debug!("Failed to fetch {key:?} from cache: {e}");
|
|
||||||
}) {
|
|
||||||
return Some(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
let value = match fetcher.fetch(&key).await {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Couldn't fetch {key:?}: {e}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let data = serialize(&key, &value);
|
|
||||||
if let Err(e) = fs::write(file_path, data).await {
|
|
||||||
tracing::error!("Failed to cahce value for {key:?}: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(value)
|
|
||||||
});
|
|
||||||
let fetching = fetching as Pin<Box<dyn Future<Output = Option<V>> + Send + Sync>>;
|
|
||||||
let fetching = fetching.shared();
|
|
||||||
|
|
||||||
if let Some(fetching) = fetching.downgrade() {
|
|
||||||
entry.insert(fetching);
|
|
||||||
}
|
|
||||||
|
|
||||||
Either::Right(fetching)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
use anyhow::{Context, anyhow, bail};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tokio::fs;
|
|
||||||
|
|
||||||
use crate::xdg::BASE_DIRECTORIES;
|
|
||||||
|
|
||||||
const CONFIG_FILE: &str = "config.toml";
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Config {
|
|
||||||
pub immich: Option<ImmichLogin>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ImmichLogin {
|
|
||||||
pub url: String,
|
|
||||||
pub api_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
pub async fn load() -> anyhow::Result<Self> {
|
|
||||||
let Some(config_path) = BASE_DIRECTORIES.get_config_file(CONFIG_FILE) else {
|
|
||||||
bail!("No config file exists")
|
|
||||||
};
|
|
||||||
|
|
||||||
let config = fs::read_to_string(&config_path)
|
|
||||||
.await
|
|
||||||
.context(anyhow!("Failed to read config file at {config_path:?}"))?;
|
|
||||||
|
|
||||||
toml::from_str(&config).context(anyhow!(
|
|
||||||
"Failed to deserialize config file at {config_path:?}"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save(&self) -> anyhow::Result<()> {
|
|
||||||
let config_path = BASE_DIRECTORIES
|
|
||||||
.place_config_file(CONFIG_FILE)
|
|
||||||
.context(anyhow!("Failed to create config folder"))?;
|
|
||||||
|
|
||||||
let config = toml::to_string_pretty(self)?;
|
|
||||||
|
|
||||||
fs::write(&config_path, config)
|
|
||||||
.await
|
|
||||||
.context(anyhow!("Failed to write config file to {config_path:?}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
267
src/main.rs
267
src/main.rs
@@ -1,43 +1,61 @@
|
|||||||
// Prevent console window in addition to Slint window in Windows release builds when, e.g., starting the app via file manager. Ignored on other platforms.
|
// Prevent console window in addition to Slint window in Windows release builds when, e.g., starting the app via file manager. Ignored on other platforms.
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use std::{mem, ops::Deref, sync::Arc};
|
use std::{mem, ops::Deref};
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use immich_sdk::AssetId;
|
use immich_sdk::AssetId;
|
||||||
|
use kameo::actor::{ActorRef, Spawn};
|
||||||
use slint::{
|
use slint::{
|
||||||
ComponentHandle as _, Image, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString,
|
ComponentHandle as _, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString,
|
||||||
VecModel, Weak,
|
VecModel, Weak,
|
||||||
};
|
};
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{Api, TimeBucketKey},
|
api::{Api, GetAssetThumbnail, GetTimeBucket, GetTimeBuckets, TimeBucketKey},
|
||||||
config::Config,
|
ui::{AppWindow, ImageBucket},
|
||||||
ui::AppWindow,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Use jemalloc to reduce memory fragmentation.
|
mod api;
|
||||||
#[global_allocator]
|
mod thumbhash;
|
||||||
static ALLOCATOR: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
|
|
||||||
|
|
||||||
pub mod api;
|
|
||||||
pub mod cachemap;
|
|
||||||
pub mod config;
|
|
||||||
pub mod thumbhash;
|
|
||||||
pub mod xdg;
|
|
||||||
|
|
||||||
pub const CRATE_NAME: &str = env!("CARGO_CRATE_NAME");
|
|
||||||
|
|
||||||
mod ui {
|
mod ui {
|
||||||
slint::include_modules!();
|
slint::include_modules!();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(clap::Parser)]
|
#[derive(clap::Parser)]
|
||||||
struct Opt {}
|
struct Opt {
|
||||||
|
#[clap(long, env = "IMMICH_BASE_URL")]
|
||||||
|
pub immich_base_url: String,
|
||||||
|
|
||||||
|
#[clap(long, env = "IMMICH_API_KEY")]
|
||||||
|
pub immich_api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// enum ApiReq<M: Send + 'static>
|
||||||
|
// where
|
||||||
|
// Api: Message<M>,
|
||||||
|
// {
|
||||||
|
// AskRequest(
|
||||||
|
// AskRequest<
|
||||||
|
// 'static,
|
||||||
|
// Api,
|
||||||
|
// M,
|
||||||
|
// kameo::request::WithoutRequestTimeout,
|
||||||
|
// kameo::request::WithoutRequestTimeout,
|
||||||
|
// >,
|
||||||
|
// ),
|
||||||
|
// PendingReply(PendingReply<M, <Api as Message<M>>::Reply>),
|
||||||
|
// }
|
||||||
|
|
||||||
|
// enum ApiReqs {
|
||||||
|
// GetBuckets(ApiReq<GetTimeBuckets>),
|
||||||
|
// GetBucket(ApiReq<GetTimeBucket>),
|
||||||
|
// }
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let _opt = Opt::parse();
|
let opt = Opt::parse();
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_max_level(Level::DEBUG)
|
.with_max_level(Level::DEBUG)
|
||||||
@@ -46,59 +64,18 @@ fn main() -> anyhow::Result<()> {
|
|||||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
let _rt_guard = runtime.enter();
|
let _rt_guard = runtime.enter();
|
||||||
|
|
||||||
let config_ = runtime
|
let immich_config =
|
||||||
.block_on(Config::load())
|
immich_sdk::Config::new(opt.immich_base_url).with_api_key(opt.immich_api_key);
|
||||||
.inspect_err(|e| tracing::debug!("{e}"))
|
let api = Api::new(immich_sdk::Client::new(immich_config).unwrap());
|
||||||
.map(Arc::new)
|
let api_ = Api::spawn(api);
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let app = ui::AppWindow::new()?;
|
let app = ui::AppWindow::new()?;
|
||||||
let global = app.global::<ui::Global>();
|
let global = app.global::<ui::Global>();
|
||||||
|
|
||||||
let config = Arc::clone(&config_);
|
|
||||||
let app_weak = app.as_weak();
|
|
||||||
global.on_login_api_key(move |url, api_key| {
|
|
||||||
tracing::debug!("url: {url}, api_key: {api_key}");
|
|
||||||
|
|
||||||
let mut config = config.as_ref().clone();
|
|
||||||
let immich_config = config::ImmichLogin {
|
|
||||||
url: url.to_string(),
|
|
||||||
api_key: api_key.to_string(),
|
|
||||||
};
|
|
||||||
config.immich = Some(immich_config.clone());
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = config.save().await {
|
|
||||||
tracing::error!("{e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = app_weak.upgrade_in_event_loop(move |app| {
|
|
||||||
start_api(&app, &immich_config);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(immich) = &config_.immich {
|
|
||||||
start_api(&app, immich);
|
|
||||||
}
|
|
||||||
|
|
||||||
app.run()?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_api(app: &AppWindow, immich: &config::ImmichLogin) {
|
|
||||||
let immich_config = immich_sdk::Config::new(&immich.url).with_api_key(&immich.api_key);
|
|
||||||
let api_ = Api::new(immich_sdk::Client::new(immich_config).unwrap());
|
|
||||||
|
|
||||||
let global = app.global::<ui::Global>();
|
|
||||||
global.set_logged_in(true);
|
|
||||||
global.set_image_buckets(ModelRc::new(VecModel::default()));
|
|
||||||
|
|
||||||
let app_weak = app.as_weak();
|
let app_weak = app.as_weak();
|
||||||
let api = api_.clone();
|
let api = api_.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let Ok(buckets) = api.get_time_buckets().await else {
|
let Ok(buckets) = api.ask(GetTimeBuckets).await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,8 +94,7 @@ fn start_api(app: &AppWindow, immich: &config::ImmichLogin) {
|
|||||||
.map(|_| ui::ImagePreview {
|
.map(|_| ui::ImagePreview {
|
||||||
asset_id: SharedString::new(),
|
asset_id: SharedString::new(),
|
||||||
image: preview_image.clone(),
|
image: preview_image.clone(),
|
||||||
ratio: 1.0,
|
kind: ui::PreviewKind::None,
|
||||||
kind: ui::ImageKind::None,
|
|
||||||
})
|
})
|
||||||
.collect::<VecModel<_>>(),
|
.collect::<VecModel<_>>(),
|
||||||
),
|
),
|
||||||
@@ -150,120 +126,18 @@ fn start_api(app: &AppWindow, immich: &config::ImmichLogin) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let app_weak = app.as_weak();
|
app.run()?;
|
||||||
let api = api_.clone();
|
|
||||||
global.on_view_image(move |asset_id_str| {
|
|
||||||
tracing::info!("on_view_image({asset_id_str})");
|
|
||||||
let api = api.clone();
|
|
||||||
let app_weak = app_weak.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let asset_id: AssetId = asset_id_str.parse().unwrap();
|
|
||||||
let asset = api.get_asset(asset_id).await.unwrap();
|
|
||||||
let image = asset.thumbnail.clone();
|
|
||||||
tracing::info!("got image for {asset_id_str}");
|
|
||||||
|
|
||||||
let _ = app_weak.upgrade_in_event_loop(move |app| {
|
Ok(())
|
||||||
let global = app.global::<ui::Global>();
|
|
||||||
let existing = global.get_viewed_image();
|
|
||||||
|
|
||||||
if existing.asset_id != asset_id_str {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
global.set_viewed_image(ui::ImagePreview {
|
|
||||||
asset_id: asset_id_str,
|
|
||||||
ratio: image.size().width as f32 / image.size().height as f32,
|
|
||||||
image: slint::Image::from_rgba8(image),
|
|
||||||
kind: ui::ImageKind::Original,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let app_weak = app.as_weak();
|
|
||||||
let api = api_.clone();
|
|
||||||
global.on_load_albums(move || {
|
|
||||||
tracing::info!("on_load_albums()");
|
|
||||||
let api = api.clone();
|
|
||||||
let app_weak = app_weak.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let Ok(albums) = api
|
|
||||||
.get_album_list()
|
|
||||||
.await
|
|
||||||
.inspect_err(|e| tracing::error!("Failed to load albums: {e}"))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::info!("loaded {} album covers", albums.len());
|
|
||||||
|
|
||||||
let mut thumbnail_tasks = vec![];
|
|
||||||
for album in &albums {
|
|
||||||
let Some(asset_id) = album.album_thumbnail_asset_id else {
|
|
||||||
thumbnail_tasks.push(None);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let api = api.clone();
|
|
||||||
let task = tokio::spawn(async move {
|
|
||||||
api.get_asset_thumbnail(asset_id)
|
|
||||||
.await
|
|
||||||
.inspect_err(|e| tracing::error!("Failed to get album thumbnail: {e}"))
|
|
||||||
});
|
|
||||||
thumbnail_tasks.push(Some(task));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut thumbnails = vec![];
|
|
||||||
for task in thumbnail_tasks {
|
|
||||||
if let Some(task) = task
|
|
||||||
&& let Ok(Ok(thumbnail)) = task.await
|
|
||||||
{
|
|
||||||
thumbnails.push(Some(thumbnail.thumbnail.clone()));
|
|
||||||
} else {
|
|
||||||
thumbnails.push(None);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = app_weak.upgrade_in_event_loop(move |app| {
|
|
||||||
let albums = albums
|
|
||||||
.into_iter()
|
|
||||||
.zip(thumbnails)
|
|
||||||
.map(|(album, thumbnail)| {
|
|
||||||
ui::AlbumCover {
|
|
||||||
asset_count: album.asset_count as i32,
|
|
||||||
description: album.description.into(),
|
|
||||||
id: album.id.to_shared_string(),
|
|
||||||
name: album.album_name.into(),
|
|
||||||
thumbnail: thumbnail
|
|
||||||
.map(|t| ui::ImagePreview {
|
|
||||||
asset_id: album
|
|
||||||
.album_thumbnail_asset_id
|
|
||||||
.unwrap()
|
|
||||||
.to_shared_string(),
|
|
||||||
kind: ui::ImageKind::Thumbnail,
|
|
||||||
ratio: t.width() as f32 / t.height() as f32,
|
|
||||||
image: Image::from_rgba8(t),
|
|
||||||
})
|
|
||||||
.unwrap_or_default(), // TODO
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<VecModel<_>>();
|
|
||||||
let albums = ModelRc::new(albums);
|
|
||||||
|
|
||||||
let global = app.global::<ui::Global>();
|
|
||||||
global.set_albums(albums);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_timeline_visibility(app: &AppWindow, api: Arc<Api>, scroll: f32) {
|
fn calculate_timeline_visibility(app: &AppWindow, api: ActorRef<Api>, scroll: f32) {
|
||||||
let global = app.global::<ui::Global>();
|
let global = app.global::<ui::Global>();
|
||||||
global.set_timeline_scroll(scroll);
|
global.set_timeline_scroll(scroll);
|
||||||
|
|
||||||
let window_height = app.get_window_height();
|
let window_height = app.get_window_height();
|
||||||
let visible_range = scroll..=(scroll + window_height);
|
let visible_range = scroll..=(scroll + window_height);
|
||||||
let buckets = get_image_buckets(app);
|
let buckets = get_image_buckets(&app);
|
||||||
|
|
||||||
for i in 0..buckets.row_count() {
|
for i in 0..buckets.row_count() {
|
||||||
let Some(mut bucket) = buckets.row_data(i) else {
|
let Some(mut bucket) = buckets.row_data(i) else {
|
||||||
@@ -272,7 +146,7 @@ fn calculate_timeline_visibility(app: &AppWindow, api: Arc<Api>, scroll: f32) {
|
|||||||
let top_y = bucket.y;
|
let top_y = bucket.y;
|
||||||
let bottom_y = bucket.y + bucket.height;
|
let bottom_y = bucket.y + bucket.height;
|
||||||
|
|
||||||
let is_visible = &top_y <= visible_range.end() && visible_range.start() <= &bottom_y;
|
let is_visible = &top_y <= &visible_range.end() && visible_range.start() <= &bottom_y;
|
||||||
|
|
||||||
let visibility = if is_visible {
|
let visibility = if is_visible {
|
||||||
ui::Visibility::InView
|
ui::Visibility::InView
|
||||||
@@ -303,12 +177,12 @@ fn calculate_timeline_visibility(app: &AppWindow, api: Arc<Api>, scroll: f32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_timeline_layout(app: &AppWindow, api: Arc<Api>, timeline_width: f32) {
|
fn calculate_timeline_layout(app: &AppWindow, api: ActorRef<Api>, timeline_width: f32) {
|
||||||
let global = app.global::<ui::Global>();
|
let global = app.global::<ui::Global>();
|
||||||
let min_image_size = global.get_min_image_size();
|
let min_image_size = global.get_min_image_size();
|
||||||
let image_margin = global.get_image_margin();
|
let image_margin = global.get_image_margin();
|
||||||
let min_size_with_margin = min_image_size + image_margin;
|
let min_size_with_margin = min_image_size + image_margin;
|
||||||
let buckets = get_image_buckets(app);
|
let buckets = get_image_buckets(&app);
|
||||||
|
|
||||||
let count_x = (timeline_width / min_size_with_margin).floor() as usize;
|
let count_x = (timeline_width / min_size_with_margin).floor() as usize;
|
||||||
let remaining_length = timeline_width.rem_euclid(min_size_with_margin);
|
let remaining_length = timeline_width.rem_euclid(min_size_with_margin);
|
||||||
@@ -342,13 +216,12 @@ fn calculate_timeline_layout(app: &AppWindow, api: Arc<Api>, timeline_width: f32
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AppImageBuckets {
|
struct AppImageBuckets {
|
||||||
buckets: ModelRc<ui::ImageBucket>,
|
buckets: ModelRc<ImageBucket>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Deref for AppImageBuckets {
|
impl Deref for AppImageBuckets {
|
||||||
type Target = VecModel<ui::ImageBucket>;
|
type Target = VecModel<ImageBucket>;
|
||||||
|
|
||||||
#[track_caller]
|
|
||||||
fn deref(&self) -> &Self::Target {
|
fn deref(&self) -> &Self::Target {
|
||||||
self.buckets
|
self.buckets
|
||||||
.as_any()
|
.as_any()
|
||||||
@@ -357,7 +230,7 @@ impl Deref for AppImageBuckets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_image_buckets(app: &AppWindow) -> impl Deref<Target = VecModel<ui::ImageBucket>> {
|
fn get_image_buckets(app: &AppWindow) -> impl Deref<Target = VecModel<ImageBucket>> {
|
||||||
let global = app.global::<ui::Global>();
|
let global = app.global::<ui::Global>();
|
||||||
AppImageBuckets {
|
AppImageBuckets {
|
||||||
buckets: global.get_image_buckets(),
|
buckets: global.get_image_buckets(),
|
||||||
@@ -374,7 +247,7 @@ fn placeholder_preview() -> slint::Image {
|
|||||||
|
|
||||||
fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) {
|
fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) {
|
||||||
let buckets = get_image_buckets(app);
|
let buckets = get_image_buckets(app);
|
||||||
let Some(i) = buckets.iter().position(|b| b.key == time_bucket) else {
|
let Some(i) = buckets.iter().position(|b| &b.key == &time_bucket) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let bucket = buckets.row_data(i).expect("i is in the list");
|
let bucket = buckets.row_data(i).expect("i is in the list");
|
||||||
@@ -384,7 +257,7 @@ fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) {
|
|||||||
let Some(mut preview) = bucket.previews.row_data(j) else {
|
let Some(mut preview) = bucket.previews.row_data(j) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
preview.kind = ui::ImageKind::None;
|
preview.kind = ui::PreviewKind::None;
|
||||||
preview.image = placeholder_preview.clone();
|
preview.image = placeholder_preview.clone();
|
||||||
bucket.previews.set_row_data(j, preview);
|
bucket.previews.set_row_data(j, preview);
|
||||||
}
|
}
|
||||||
@@ -392,16 +265,21 @@ fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) {
|
|||||||
// TODO: write `bucket` into `buckets?`
|
// TODO: write `bucket` into `buckets?`
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak<AppWindow>, api: Arc<Api>) {
|
fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak<AppWindow>, api: ActorRef<Api>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let Ok(api_bucket) = api.get_time_bucket(time_bucket.clone()).await else {
|
let Ok(api_bucket) = api
|
||||||
|
.ask(GetTimeBucket {
|
||||||
|
time_bucket: time_bucket.clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = app_weak.upgrade_in_event_loop(move |app| {
|
let _ = app_weak.upgrade_in_event_loop(move |app| {
|
||||||
let buckets = get_image_buckets(&app);
|
let buckets = get_image_buckets(&app);
|
||||||
|
|
||||||
let Some(i) = buckets.iter().position(|b| b.key == time_bucket) else {
|
let Some(i) = buckets.iter().position(|b| &b.key == &time_bucket) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut bucket = buckets.row_data(i).expect("i is in the list");
|
let mut bucket = buckets.row_data(i).expect("i is in the list");
|
||||||
@@ -411,8 +289,7 @@ fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak<AppWindow>, api: Arc<A
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|entry| ui::ImagePreview {
|
.map(|entry| ui::ImagePreview {
|
||||||
asset_id: entry.id.to_shared_string(), // slint doesn't have a uuid type
|
asset_id: entry.id.to_shared_string(), // slint doesn't have a uuid type
|
||||||
kind: ui::ImageKind::Thumbhash,
|
kind: ui::PreviewKind::Thumbhash,
|
||||||
ratio: entry.ratio as f32,
|
|
||||||
// TODO: don't unwrap
|
// TODO: don't unwrap
|
||||||
image: entry
|
image: entry
|
||||||
.thumbhash
|
.thumbhash
|
||||||
@@ -433,13 +310,13 @@ fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak<AppWindow>, api: Arc<A
|
|||||||
|
|
||||||
fn load_thumbnail(
|
fn load_thumbnail(
|
||||||
time_bucket: TimeBucketKey,
|
time_bucket: TimeBucketKey,
|
||||||
asset_id: AssetId,
|
id: AssetId,
|
||||||
app_weak: Weak<AppWindow>,
|
app_weak: Weak<AppWindow>,
|
||||||
api: Arc<Api>,
|
api: ActorRef<Api>,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tracing::debug!("Fetching thumbnail for {asset_id}");
|
tracing::debug!("Fetching thumbnail for {id}");
|
||||||
let thumbnail = match api.get_asset_thumbnail(asset_id).await {
|
let thumbnail = match api.ask(GetAssetThumbnail { id }).await {
|
||||||
Ok(thumbnail) => thumbnail,
|
Ok(thumbnail) => thumbnail,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("{e:?}");
|
tracing::error!("{e:?}");
|
||||||
@@ -449,20 +326,18 @@ fn load_thumbnail(
|
|||||||
|
|
||||||
let _ = app_weak.upgrade_in_event_loop(move |app| {
|
let _ = app_weak.upgrade_in_event_loop(move |app| {
|
||||||
let buckets = get_image_buckets(&app);
|
let buckets = get_image_buckets(&app);
|
||||||
let Some(i) = buckets.iter().position(|b| b.key == time_bucket) else {
|
let Some(i) = buckets.iter().position(|b| &b.key == &time_bucket) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let bucket = buckets.row_data(i).expect("i is in the list");
|
let bucket = buckets.row_data(i).expect("i is in the list");
|
||||||
|
|
||||||
let id_str = asset_id.to_string();
|
let id_str = id.to_string();
|
||||||
let Some(i) = bucket.previews.iter().position(|p| p.asset_id == id_str) else {
|
let Some(i) = bucket.previews.iter().position(|p| &p.asset_id == &id_str) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut preview = bucket.previews.row_data(i).expect("i is in the list");
|
let mut preview = bucket.previews.row_data(i).expect("i is in the list");
|
||||||
preview.image = slint::Image::from_rgba8(thumbnail.thumbnail.clone());
|
preview.image = slint::Image::from_rgba8(thumbnail.thumbnail.clone());
|
||||||
preview.kind = ui::ImageKind::Thumbnail;
|
preview.kind = ui::PreviewKind::Thumbnail;
|
||||||
preview.ratio =
|
|
||||||
thumbnail.thumbnail.width() as f32 / thumbnail.thumbnail.height() as f32;
|
|
||||||
bucket.previews.set_row_data(i, preview);
|
bucket.previews.set_row_data(i, preview);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
use xdg::BaseDirectories;
|
|
||||||
|
|
||||||
use crate::CRATE_NAME;
|
|
||||||
|
|
||||||
pub static BASE_DIRECTORIES: LazyLock<BaseDirectories> =
|
|
||||||
LazyLock::new(|| BaseDirectories::with_prefix(CRATE_NAME));
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { AlbumCover } from "types.slint";
|
|
||||||
import { Global } from "global.slint";
|
|
||||||
import { ScrollView, HorizontalBox, Palette } from "std-widgets.slint";
|
|
||||||
import { ImagePreview } from "timeline.slint";
|
|
||||||
|
|
||||||
component AlbumCover {
|
|
||||||
in property <AlbumCover> album;
|
|
||||||
|
|
||||||
states [
|
|
||||||
pressed when touch.pressed: {
|
|
||||||
click-effect.opacity: 0.2;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
click-effect.opacity: 0;
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
background: Palette.alternate-background;
|
|
||||||
border-radius: 24px;
|
|
||||||
clip: true;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 20px;
|
|
||||||
|
|
||||||
ImagePreview {
|
|
||||||
preview: album.thumbnail;
|
|
||||||
size: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: album.name;
|
|
||||||
horizontal-alignment: left;
|
|
||||||
vertical-alignment: center;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
click_effect := Rectangle {
|
|
||||||
background: Palette.accent-foreground;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
touch := TouchArea {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component Albums {
|
|
||||||
private property <[AlbumCover]> albums: Global.albums;
|
|
||||||
|
|
||||||
property <length> min-image-size: Global.min-image-size;
|
|
||||||
property <length> min-size-with-margin: min-image-size + Global.image-margin;
|
|
||||||
|
|
||||||
ScrollView {
|
|
||||||
mouse-drag-pan-enabled: true;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
VerticalLayout {
|
|
||||||
min-width: min-image-size;
|
|
||||||
alignment: start;
|
|
||||||
spacing: 10px;
|
|
||||||
|
|
||||||
for album[i] in albums : AlbumCover {
|
|
||||||
album: album;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,22 +1,142 @@
|
|||||||
import { AboutSlint, Button, Palette, HorizontalBox, ScrollView } from "std-widgets.slint";
|
import { AboutSlint, Button, Palette, HorizontalBox, ScrollView } from "std-widgets.slint";
|
||||||
import { Timeline } from "timeline.slint";
|
|
||||||
import { ImageViewer } from "image-viewer.slint";
|
|
||||||
import { LoginView } from "login.slint";
|
|
||||||
import { Header } from "header.slint";
|
|
||||||
import { Footer, FooterButton } from "footer.slint";
|
|
||||||
|
|
||||||
import { Global } from "global.slint";
|
enum PreviewKind {
|
||||||
import { Albums } from "albums.slint";
|
None,
|
||||||
export { Global }
|
Thumbhash,
|
||||||
|
Thumbnail,
|
||||||
enum View {
|
|
||||||
Timeline,
|
|
||||||
Albums
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ImagePreview {
|
||||||
|
asset_id: string,
|
||||||
|
image: image,
|
||||||
|
kind: PreviewKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Visibility {
|
||||||
|
Hidden,
|
||||||
|
NearView,
|
||||||
|
InView,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ImageBucket {
|
||||||
|
key: string,
|
||||||
|
title: string,
|
||||||
|
count: int,
|
||||||
|
previews: [ImagePreview],
|
||||||
|
y: length,
|
||||||
|
height: length,
|
||||||
|
visibility: Visibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
export global Global {
|
||||||
|
in-out property <length> min-image-size: 100px;
|
||||||
|
in-out property <length> image-margin: 2px;
|
||||||
|
in-out property <[ImageBucket]> image-buckets: [
|
||||||
|
{ key: "2026-02-01", title: "Feb 1, 2026", count: 12 },
|
||||||
|
{ key: "2026-02-02", title: "Feb 2, 2026", count: 12 },
|
||||||
|
{ key: "2026-02-03", title: "Feb 3, 2026", count: 12 },
|
||||||
|
];
|
||||||
|
in property <length> timeline-height;
|
||||||
|
in property <length> timeline-width;
|
||||||
|
in property <length> timeline-scroll;
|
||||||
|
callback set-timeline-width(length);
|
||||||
|
callback timeline-scrolled(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
component Header inherits Rectangle {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: Palette.alternate-background;
|
||||||
|
|
||||||
|
HorizontalBox {
|
||||||
|
height: parent.height;
|
||||||
|
Text {
|
||||||
|
text: "immich";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component ImagePreview inherits Rectangle {
|
||||||
|
in property <image> preview;
|
||||||
|
in property <length> size: 32px;
|
||||||
|
width: size;
|
||||||
|
height: size;
|
||||||
|
|
||||||
|
Image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
source: preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
touch := TouchArea {
|
||||||
|
clicked => {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component TimelineBlock inherits VerticalLayout {
|
||||||
|
in property <int> index: -1;
|
||||||
|
in-out property <ImageBucket> bucket;
|
||||||
|
|
||||||
|
property <length> min-image-size: Global.min-image-size;
|
||||||
|
property <length> min-size-with-margin: min-image-size + Global.image-margin;
|
||||||
|
property <int> count-x: Math.floor(self.width / min-size-with-margin); // TODO: or is it ceil?
|
||||||
|
property <int> count-y: Math.ceil(bucket.count / count-x);
|
||||||
|
|
||||||
|
function calc-image-size() -> length {
|
||||||
|
let remaining-length = Math.mod(self.width, min-size-with-margin);
|
||||||
|
min-image-size + remaining-length / count-x
|
||||||
|
}
|
||||||
|
|
||||||
|
property <length> image-size: calc-image-size();
|
||||||
|
property <length> image-size-with-margin: image-size + Global.image-margin;
|
||||||
|
|
||||||
|
property <length> title-box-height: 36px;
|
||||||
|
height: title-box.height + count-y * image-size-with-margin;
|
||||||
|
|
||||||
|
y: bucket.y;
|
||||||
|
min-width: min-image-size;
|
||||||
|
alignment: start;
|
||||||
|
|
||||||
|
title-box := HorizontalBox {
|
||||||
|
alignment: space-between;
|
||||||
|
height: title-box-height;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: bucket.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: checkbox thingy
|
||||||
|
Text {
|
||||||
|
text: "O";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
image-box := Rectangle {
|
||||||
|
width: 100%;
|
||||||
|
height: count-y * image-size-with-margin;
|
||||||
|
|
||||||
|
for preview[i] in bucket.previews : ImagePreview {
|
||||||
|
preview: preview.image;
|
||||||
|
size: image-size;
|
||||||
|
x: Global.image-margin / 2 + Math.mod(i, count-x) * (Global.image-margin + image-size);
|
||||||
|
y: Math.floor(i / count-x) * (image-size + Global.image-margin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// component ImageViewer inherits Rectangle {
|
||||||
|
// in property <image> image;
|
||||||
|
|
||||||
|
// width: 100%;
|
||||||
|
// height: 100%;
|
||||||
|
|
||||||
|
// background: black;
|
||||||
|
// }
|
||||||
|
|
||||||
export component AppWindow inherits Window {
|
export component AppWindow inherits Window {
|
||||||
out property <length> window-height: self.height;
|
out property <length> window-height: self.height;
|
||||||
out property <View> view: View.Timeline;
|
|
||||||
|
|
||||||
// Do not base preferred-width on children
|
// Do not base preferred-width on children
|
||||||
preferred-width: 480px;
|
preferred-width: 480px;
|
||||||
@@ -31,37 +151,30 @@ export component AppWindow inherits Window {
|
|||||||
|
|
||||||
Header {}
|
Header {}
|
||||||
|
|
||||||
if !Global.logged-in: LoginView {}
|
ScrollView {
|
||||||
if Global.logged-in && view == View.Timeline: Timeline {}
|
mouse-drag-pan-enabled: true;
|
||||||
if Global.logged-in && view == View.Albums: Albums {}
|
viewport-height: rect.height;
|
||||||
|
|
||||||
Footer {
|
changed viewport-y => {
|
||||||
FooterButton {
|
Global.timeline-scrolled(-self.viewport-y);
|
||||||
title: "Photos";
|
|
||||||
icon: @image-url("../assets/photos.svg");
|
|
||||||
clicked => { view = View.Timeline }
|
|
||||||
}
|
}
|
||||||
FooterButton {
|
|
||||||
title: "Search";
|
rect := Rectangle {
|
||||||
icon: @image-url("../assets/search.svg");
|
y: 0;
|
||||||
}
|
x: 0;
|
||||||
FooterButton {
|
width: root.width;
|
||||||
title: "Album";
|
height: Global.timeline-height;
|
||||||
icon: @image-url("../assets/album.svg");
|
preferred-width: self.width;
|
||||||
clicked => {
|
preferred-height: self.height;
|
||||||
view = View.Albums;
|
for bucket[i] in Global.image-buckets : Rectangle {
|
||||||
Global.load-albums();
|
if bucket.visibility != Visibility.InView : TimelineBlock {
|
||||||
|
width: root.width;
|
||||||
|
index: i;
|
||||||
|
bucket: bucket;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FooterButton {
|
|
||||||
title: "Library";
|
|
||||||
icon: @image-url("../assets/album.svg"); // TODO
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if Global.viewed-image.asset-id != "" : ImageViewer {
|
|
||||||
image: Global.viewed-image.image;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Palette } from "std-widgets.slint";
|
|
||||||
|
|
||||||
export component FooterButton inherits Rectangle {
|
|
||||||
in property <string> title: "Button";
|
|
||||||
in property <image> icon;
|
|
||||||
callback clicked <=> touch.clicked;
|
|
||||||
|
|
||||||
states [
|
|
||||||
pressed when touch.pressed: {
|
|
||||||
background: #0000ff30; // TODO: palette
|
|
||||||
}
|
|
||||||
hovered when touch.has-hover: {
|
|
||||||
background: #0000ff15; // TODO: palette
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
background: #0000;
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
animate background {
|
|
||||||
duration: 0.1s;
|
|
||||||
easing: ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
border-radius: 20px;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 10px;
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
Image {
|
|
||||||
source: icon;
|
|
||||||
colorize: Palette.accent-background;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: title;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
touch := TouchArea {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component Footer inherits Rectangle {
|
|
||||||
width: 100%;
|
|
||||||
height: 88px;
|
|
||||||
background: Palette.alternate-background;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
height: parent.height;
|
|
||||||
spacing: 16px;
|
|
||||||
padding-top: 8px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
padding-left: 16px;
|
|
||||||
padding-right: 16px;
|
|
||||||
|
|
||||||
@children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { ImageBucket, ImagePreview, AlbumCover } from "types.slint";
|
|
||||||
|
|
||||||
export global Global {
|
|
||||||
in-out property <bool> logged-in: false;
|
|
||||||
in-out property <length> min-image-size: 88px;
|
|
||||||
in-out property <length> image-margin: 2px;
|
|
||||||
in-out property <ImagePreview> viewed-image;
|
|
||||||
in-out property <[AlbumCover]> albums;
|
|
||||||
in-out property <[ImageBucket]> image-buckets: [
|
|
||||||
{ key: "2026-02-01", title: "Feb 1, 2026", count: 12 },
|
|
||||||
{ key: "2026-02-02", title: "Feb 2, 2026", count: 12 },
|
|
||||||
{ key: "2026-02-03", title: "Feb 3, 2026", count: 12 },
|
|
||||||
];
|
|
||||||
in property <length> timeline-height;
|
|
||||||
in property <length> timeline-width;
|
|
||||||
in property <length> timeline-scroll;
|
|
||||||
callback login-api-key(url: string, api_key: string);
|
|
||||||
callback set-timeline-width(length);
|
|
||||||
callback timeline-scrolled(length);
|
|
||||||
callback view-image(string);
|
|
||||||
callback load-albums();
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { HorizontalBox, Palette } from "std-widgets.slint";
|
|
||||||
|
|
||||||
export component Header inherits Rectangle {
|
|
||||||
width: 100%;
|
|
||||||
height: 48px;
|
|
||||||
background: Palette.alternate-background;
|
|
||||||
|
|
||||||
HorizontalBox {
|
|
||||||
height: parent.height;
|
|
||||||
Text {
|
|
||||||
text: "immich";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { Global } from "global.slint";
|
|
||||||
|
|
||||||
export component ImageViewer inherits Rectangle {
|
|
||||||
in property <image> image;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
background: black;
|
|
||||||
|
|
||||||
enter-animation := Timer {
|
|
||||||
running: true;
|
|
||||||
interval: 1ms;
|
|
||||||
triggered => {
|
|
||||||
self.running = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exit-animation := Timer {
|
|
||||||
running: false;
|
|
||||||
interval: 0.2s;
|
|
||||||
triggered => {
|
|
||||||
self.running = false;
|
|
||||||
Global.viewed-image.asset-id = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
states [
|
|
||||||
entering when enter-animation.running: {
|
|
||||||
root.opacity: 0.0;
|
|
||||||
out {
|
|
||||||
animate root.opacity {
|
|
||||||
duration: 0.15s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exiting when exit-animation.running: {
|
|
||||||
root.opacity: 0.0;
|
|
||||||
in {
|
|
||||||
animate root.opacity {
|
|
||||||
duration: exit-animation.interval;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entered: {
|
|
||||||
root.opacity: 1.0;
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
sgh := SwipeGestureHandler {
|
|
||||||
enabled: !enter-animation.running && !exit-animation.running;
|
|
||||||
handle-swipe-down: true;
|
|
||||||
handle-swipe-up: true;
|
|
||||||
|
|
||||||
swiped() => {
|
|
||||||
if self.current-position.y < self.pressed-position.y {
|
|
||||||
debug ("todo: handle swiped up")
|
|
||||||
} else {
|
|
||||||
exit-animation.running = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Image {
|
|
||||||
source: image;
|
|
||||||
image-fit: ImageFit.contain;
|
|
||||||
width: root.width;
|
|
||||||
height: root.height;
|
|
||||||
|
|
||||||
function calc-y() -> length {
|
|
||||||
parent.y + parent.height / 2 - self.height / 2
|
|
||||||
}
|
|
||||||
y: calc-y();
|
|
||||||
|
|
||||||
states [
|
|
||||||
swiping when sgh.swiping: {
|
|
||||||
y: calc-y() + sgh.current-position.y - sgh.pressed-position.y;
|
|
||||||
}
|
|
||||||
exiting when exit-animation.running: {
|
|
||||||
y: parent.height;
|
|
||||||
in {
|
|
||||||
animate y {
|
|
||||||
duration: exit-animation.interval;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { TextEdit, LineEdit, Button } from "std-widgets.slint";
|
|
||||||
import { Global } from "global.slint";
|
|
||||||
|
|
||||||
export component LoginView inherits VerticalLayout {
|
|
||||||
padding: 16px;
|
|
||||||
alignment: center;
|
|
||||||
spacing: 8px;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Image {
|
|
||||||
width: 128px;
|
|
||||||
height: self.width;
|
|
||||||
source: @image-url("../assets/immich-logo.svg");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
url := LineEdit {
|
|
||||||
placeholder-text: "immich url";
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
api-key := LineEdit {
|
|
||||||
placeholder-text: "immich api key";
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
text: "Login";
|
|
||||||
height: 40px;
|
|
||||||
clicked => {
|
|
||||||
Global.login-api-key(url.text, api-key.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import { ScrollView, Palette } from "std-widgets.slint";
|
|
||||||
import { Global } from "global.slint";
|
|
||||||
import { ImageBucket, Visibility, ImagePreview } from "types.slint";
|
|
||||||
|
|
||||||
export component ImagePreview inherits Rectangle {
|
|
||||||
in property <ImagePreview> preview;
|
|
||||||
in property <length> size: 32px;
|
|
||||||
callback clicked <=> touch.clicked;
|
|
||||||
|
|
||||||
width: size;
|
|
||||||
height: size;
|
|
||||||
clip: true;
|
|
||||||
|
|
||||||
Image {
|
|
||||||
width: preview.ratio < 1.0 ? size : size * preview.ratio;
|
|
||||||
height: preview.ratio > 1.0 ? size : size / preview.ratio;
|
|
||||||
source: preview.image;
|
|
||||||
}
|
|
||||||
|
|
||||||
touch := TouchArea {}
|
|
||||||
}
|
|
||||||
|
|
||||||
component TimelineBlock inherits VerticalLayout {
|
|
||||||
in property <int> index: -1;
|
|
||||||
in-out property <ImageBucket> bucket;
|
|
||||||
|
|
||||||
property <length> min-image-size: Global.min-image-size;
|
|
||||||
property <length> min-size-with-margin: min-image-size + Global.image-margin;
|
|
||||||
property <int> count-x: Math.floor(self.width / min-size-with-margin); // TODO: or is it ceil?
|
|
||||||
property <int> count-y: Math.ceil(bucket.count / count-x);
|
|
||||||
|
|
||||||
function calc-image-size() -> length {
|
|
||||||
let remaining-length = Math.mod(self.width, min-size-with-margin);
|
|
||||||
min-image-size + remaining-length / count-x
|
|
||||||
}
|
|
||||||
|
|
||||||
property <length> image-size: calc-image-size();
|
|
||||||
property <length> image-size-with-margin: image-size + Global.image-margin;
|
|
||||||
|
|
||||||
property <length> title-box-height: 44px;
|
|
||||||
height: title-box-height + count-y * image-size-with-margin;
|
|
||||||
|
|
||||||
y: bucket.y;
|
|
||||||
min-width: min-image-size;
|
|
||||||
alignment: start;
|
|
||||||
|
|
||||||
title-box := Rectangle {
|
|
||||||
property <bool> checked: false;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
alignment: space-between;
|
|
||||||
height: title-box-height;
|
|
||||||
padding: 8px;
|
|
||||||
|
|
||||||
title := Text {
|
|
||||||
text: bucket.title;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !checked : Image {
|
|
||||||
source: @image-url("../assets/unchecked.svg");
|
|
||||||
colorize: Palette.foreground;
|
|
||||||
opacity: 0.8;
|
|
||||||
height: title.height;
|
|
||||||
width: self.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
if checked : Image {
|
|
||||||
source: @image-url("../assets/checked.svg");
|
|
||||||
colorize: Palette.accent-background;
|
|
||||||
height: title.height;
|
|
||||||
width: self.height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
title-touch := TouchArea {
|
|
||||||
clicked => {
|
|
||||||
parent.checked = !parent.checked;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
image-box := Rectangle {
|
|
||||||
width: 100%;
|
|
||||||
height: count-y * image-size-with-margin;
|
|
||||||
|
|
||||||
for preview[i] in bucket.previews : ImagePreview {
|
|
||||||
preview: preview;
|
|
||||||
size: image-size;
|
|
||||||
x: Global.image-margin / 2 + Math.mod(i, count-x) * (Global.image-margin + image-size);
|
|
||||||
y: Math.floor(i / count-x) * (image-size + Global.image-margin);
|
|
||||||
clicked => {
|
|
||||||
Global.viewed-image = preview;
|
|
||||||
Global.view-image(preview.asset-id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component ScrollHandle {
|
|
||||||
out property<float> maximum: 1;
|
|
||||||
out property<float> minimum: 0;
|
|
||||||
in-out property<float> value;
|
|
||||||
callback dragged(float);
|
|
||||||
|
|
||||||
width: handle.width * 0.66;
|
|
||||||
horizontal-stretch: 0;
|
|
||||||
vertical-stretch: 1;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
handle := Rectangle {
|
|
||||||
x: 0;
|
|
||||||
width: 64px;
|
|
||||||
height: self.width;
|
|
||||||
border-width: 3px;
|
|
||||||
border-radius: self.height / 2;
|
|
||||||
background: touch.pressed ? Palette.accent-background : Palette.alternate-background;
|
|
||||||
border-color: Palette.accent-foreground;
|
|
||||||
y: (root.height - handle.height) * (root.value - root.minimum)/(root.maximum - root.minimum);
|
|
||||||
|
|
||||||
touch := TouchArea {
|
|
||||||
moved => {
|
|
||||||
if (self.enabled && self.pressed) {
|
|
||||||
root.value = max(root.minimum, min(root.maximum,
|
|
||||||
root.value + (self.mouse-y - self.pressed-y) * (root.maximum - root.minimum) / root.height));
|
|
||||||
dragged(root.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component Timeline {
|
|
||||||
scroll-view := ScrollView {
|
|
||||||
mouse-drag-pan-enabled: true;
|
|
||||||
viewport-height: rect.height;
|
|
||||||
vertical-scrollbar-policy: always-off;
|
|
||||||
horizontal-scrollbar-policy: always-off;
|
|
||||||
|
|
||||||
scrolled => {
|
|
||||||
// sync ScrollHandle with ScrollView
|
|
||||||
scroll-handle.value = (-scroll-view.viewport-y) / scroll-view.viewport-height;
|
|
||||||
}
|
|
||||||
|
|
||||||
changed viewport-y => {
|
|
||||||
Global.timeline-scrolled(-self.viewport-y);
|
|
||||||
}
|
|
||||||
|
|
||||||
rect := Rectangle {
|
|
||||||
y: 0;
|
|
||||||
x: 0;
|
|
||||||
width: root.width;
|
|
||||||
height: Global.timeline-height;
|
|
||||||
preferred-width: self.width;
|
|
||||||
preferred-height: self.height;
|
|
||||||
for bucket[i] in Global.image-buckets : Rectangle {
|
|
||||||
if bucket.visibility == Visibility.InView : TimelineBlock {
|
|
||||||
width: root.width;
|
|
||||||
index: i;
|
|
||||||
bucket: bucket;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scroll-handle := ScrollHandle {
|
|
||||||
x: parent.x + parent.width - self.width;
|
|
||||||
height: root.height;
|
|
||||||
dragged(value) => {
|
|
||||||
// sync ScrollView with ScrollHandle
|
|
||||||
scroll-view.viewport-y = -(value * scroll-view.viewport-height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
export enum ImageKind {
|
|
||||||
None,
|
|
||||||
Thumbhash,
|
|
||||||
Thumbnail,
|
|
||||||
Original,
|
|
||||||
}
|
|
||||||
|
|
||||||
export struct ImagePreview {
|
|
||||||
asset_id: string,
|
|
||||||
|
|
||||||
// Thumbnail/thumbhash/etc
|
|
||||||
image: image,
|
|
||||||
|
|
||||||
// Image aspect ratio. (width/height)
|
|
||||||
ratio: float,
|
|
||||||
|
|
||||||
kind: ImageKind,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Visibility {
|
|
||||||
Hidden,
|
|
||||||
NearView,
|
|
||||||
InView,
|
|
||||||
}
|
|
||||||
|
|
||||||
export struct ImageBucket {
|
|
||||||
key: string,
|
|
||||||
title: string,
|
|
||||||
count: int,
|
|
||||||
previews: [ImagePreview],
|
|
||||||
y: length,
|
|
||||||
height: length,
|
|
||||||
visibility: Visibility,
|
|
||||||
}
|
|
||||||
|
|
||||||
export struct AlbumCover {
|
|
||||||
id: string,
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
thumbnail: ImagePreview,
|
|
||||||
asset_count: int,
|
|
||||||
}
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user