summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client/index.html138
-rw-r--r--src/client/src/albums.js87
-rw-r--r--src/client/src/connector.js92
-rw-r--r--src/client/src/icons.js19
-rw-r--r--src/client/src/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg1
-rwxr-xr-xsrc/client/src/icons/removefill.sh6
-rw-r--r--src/client/src/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg1
-rw-r--r--src/client/src/index.js29
-rw-r--r--src/client/src/pages.js54
-rw-r--r--src/client/src/settings.js16
-rw-r--r--src/client/src/slides.js137
-rw-r--r--src/client/src/style.css48
-rw-r--r--src/server/__main__.py20
-rw-r--r--src/server/flaskapi.py90
-rw-r--r--src/server/immich.py64
-rw-r--r--src/server/lazycachelist.py170
-rw-r--r--src/server/manager.py127
-rw-r--r--src/server/renderer.py172
-rw-r--r--src/server/settings.py32
-rw-r--r--src/server/texture.py67
-rw-r--r--src/server/window.py154
38 files changed, 1541 insertions, 0 deletions
diff --git a/src/client/index.html b/src/client/index.html
new file mode 100644
index 0000000..dcecf89
--- /dev/null
+++ b/src/client/index.html
@@ -0,0 +1,138 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
+ <script src="/src/index.js" type="module" defer></script>
+</head>
+<body class="bg-black text-white fill-white h-dvh max-h-screen flex flex-col m-0 overflow-hidden">
+ <header></header>
+ <main class="my-4 overflow-hidden h-full flex flex-col">
+ <!-- Slideshow Page -->
+ <div id="slideshow" class="hidden! overflow-hidden h-full flex flex-col">
+ <div class="flex justify-end pe-[10vw] w-full absolute bg-gradient-to-b from-black/40 z-20">
+ <div id="slideshow-share" class="z-10">
+ <button id="share" class="svg-btn p-3 size-13"><svg alt="Share" class="size-7"><use href="/__spritemap#sprite-share"></use></svg></button>
+ <button id="download" class="svg-btn p-3 size-13"><svg alt="Download" class="size-7"><use href="/__spritemap#sprite-download"></use></svg></button>
+ </div>
+ </div>
+ <div id="slideshow-carousel" class="h-full"></div>
+ <template id="carousel-cell-template">
+ <div class="carousel-cell"><img class="carousel-img" src="" /></div>
+ </template>
+ <div class="grid grid-cols-3 w-full max-w-3xl m-auto justify-items-center py-4">
+ <button id="prev-slide" class="svg-btn size-12"><svg alt="Previous Slide" class="size-full"><use href="/__spritemap#sprite-skip_previous"></use></svg></button>
+ <button id="play-pause" class="svg-btn size-12"><svg alt="Pause" class="size-full"><use href="/__spritemap#sprite-pause"></use></svg></button>
+ <button id="next-slide" class="svg-btn size-12"><svg alt="Next Slide" class="size-full"><use href="/__spritemap#sprite-skip_next"></use></svg></button>
+ </div>
+ </div>
+ <!-- Albums Page -->
+ <div id="albums" class="hidden! overflow-y-scroll"><div class="px-4 m-auto max-w-3xl">
+ <div class="p-2 sticky top-0 z-20 bg-black">
+ <input class="rounded-input mx-auto mb-4 w-full" id="album-search" placeholder="Search your albums" />
+ <button id="albums-submit" class="rounded-btn">
+ <svg class="size-6"><use href="/__spritemap#sprite-check_circle"></use></svg>
+ Select
+ </button>
+ </div>
+ <div id="albums-container" class="m-auto z-10"></div>
+ <template id="album-template">
+ <div class="album group">
+ <svg class="opacity-0 self-center size-6 group-hover:opacity-50
+ group-data-selected:opacity-100 group-data-selected:fill-blue-300" alt="Select Album">
+ <use href="/__spritemap#sprite-check_circle"></use>
+ </svg>
+ <img class="aspect-square object-cover rounded-2xl group-hover:shadow-md" />
+ <div class="flex justify-between self-center text-lg">
+ <div>
+ <span class="album-name font-bold group-hover:text-blue-300"></span>
+ <div class="album-info">
+ <span><span class="album-assets-count"></span> items</span>
+ <span class="album-shared">&bull; Shared</span>
+ </div>
+ </div>
+ </div>
+ <a target="_blank" class="opacity-0 group-hover:opacity-100 absolute bottom-4 right-4">
+ <svg class="size-6" alt="View album in Immich"><use href="/__spritemap#sprite-open_in_new"></use></svg>
+ </a>
+ </div>
+ </template>
+ </div></div>
+ <!-- Settings Page -->
+ <div id="settings" class="hidden! overflow-y-scroll">
+ <form class="flex flex-col gap-4 m-auto max-w-3xl mx-auto px-4">
+ <fieldset class="rounded-fieldset">
+ <h2 class="fieldset-header">
+ <svg class="size-6 inline"><use href="/__spritemap#sprite-camera"></use></svg>
+ Immich Server
+ </h2>
+ <div>
+ <label class="settings-label">Immich URL</label>
+ <p>Complete Immich base url (e.g. <span class="font-medium">http://immich.local</span>)</p>
+ </div>
+ <input class="rounded-input" name="immich_url" type="text" />
+ <div>
+ <label class="settings-label">Immich API Key</label>
+ <p>Generate an API key in User Settings</p>
+ </div>
+ <input class="rounded-input" name="immich_api_key" type="text" />
+ </fieldset>
+ <fieldset class="rounded-fieldset">
+ <h2 class="fieldset-header">
+ <svg class="size-6 inline"><use href="/__spritemap#sprite-photo_frame"></use></svg>
+ Display
+ </h2>
+ <div class="grid md:grid-cols-[16fr_9fr] gap-4">
+ <div>
+ <label class="settings-label">Image Duration</label>
+ <p>Number of seconds each image will be displayed.</p>
+ </div>
+ <input class="my-auto rounded-input" name="image_duration" type="number" step="0.1" />
+ <div>
+ <label class="settings-label">Transition Duration</label>
+ <p>Number of seconds each image transition will take.<br>Set as 0 to disable.</p>
+ </div>
+ <input class="my-auto rounded-input" name="transition_duration" type="number" step="0.1" />
+ <div>
+ <label class="settings-label">Max Framerate</label>
+ <p>Target display framerate.<br>Simple transitions look good at 12-15 fps.</p>
+ </div>
+ <input class="my-auto rounded-input" name="max_framerate" type="number" step="0.1" />
+ <div>
+ <label class="settings-label">Display Size</label>
+ <p>Image size to load on the display.<br>Large thumbnail size is suitable for FHD.</p>
+ </div>
+ <select class="my-auto rounded-input" name="display_size">
+ <option value="thumbnail">Small Thumbnail (~15 kB)</option>
+ <option value="preview">Large Thumbnail (~400 kB)</option>
+ <option value="fullsize">Original Image</option>
+ </select>
+ <div>
+ <label class="settings-label">Max Cached Assets</label>
+ <p>Number of assets that can exist at once in RAM.<br>Each asset will take ~10x the display size in memory.</p>
+ </div>
+ <input class="my-auto rounded-input" name="max_cache_assets" type="number" />
+ </div>
+ </fieldset>
+ <input id="settings-submit" class="rounded-btn ml-auto" type="submit" value="Save Settings" />
+ <br>
+ <div class="text-white/50 text-right flex flex-col gap-1">
+ <p>Immich Pix Frame</p>
+ <p>© <a class="text-white/70" href="https://tjkeller.xyz">Tim Keller</a> 2025</p>
+ <p>GPL-3.0 License</p>
+ <p><a class="text-white/70" href="https://github.com/tjkeller-xyz/immich-pix-frame">View Source</a></p>
+ </div>
+ </form>
+ </div>
+ </main>
+ <footer class="w-full">
+ <div class="max-w-5xl m-auto">
+ <div id="menu" class="flex w-full p-2 gap-4 box-border border-t border-gray-500">
+ <a class="nav-btn" href="/slideshow"><svg class="size-6"><use href="/__spritemap#sprite-slideshow" ></use></svg><span class="max-[425px]:hidden">Slideshow</span></a>
+ <a class="nav-btn" href="/albums" ><svg class="size-6"><use href="/__spritemap#sprite-photo_album"></use></svg><span class="max-[425px]:hidden">Albums</span></a>
+ <a class="nav-btn" href="/settings" ><svg class="size-6"><use href="/__spritemap#sprite-settings" ></use></svg><span class="max-[425px]:hidden">Settings</span></a>
+ </div>
+ </div>
+ </footer>
+</body>
+</html>
diff --git a/src/client/src/albums.js b/src/client/src/albums.js
new file mode 100644
index 0000000..0ac9195
--- /dev/null
+++ b/src/client/src/albums.js
@@ -0,0 +1,87 @@
+import apiConnector from "./connector.js"
+
+class Album {
+ static albums = []
+ static albumTemplate = null
+ static albumContainer = null
+
+ constructor(data) {
+ this.data = data
+ // create clone element
+ const e = Album.albumTemplate.content.cloneNode(true)
+ e.firstElementChild.dataset.key = data.id
+ e.querySelector("a").href = apiConnector.albumSrc(data.id)
+ e.querySelector("img").src = apiConnector.assetThumbnailSrc(data.albumThumbnailAssetId)
+ e.querySelector(".album-name").textContent = data.albumName
+ e.querySelector(".album-assets-count").textContent = data.assetCount.toLocaleString()
+ if (!data.shared)
+ e.querySelector(".album-shared").remove()
+
+ Album.albums.push(this)
+ Album.albumsContainer.appendChild(e)
+ this.element = Album.albumsContainer.lastElementChild
+ if (data.selected)
+ this.element.dataset.selected = "1"
+ }
+
+ toggleVisibility(visible) { this.element.classList.toggle("hidden!", !visible) }
+
+ static albumSelect(e) {
+ // find album element
+ let album = e.target
+ while (album && !album.classList.contains("album"))
+ album = album.parentElement
+
+ if (album === null)
+ return
+
+ if (album.dataset.selected)
+ delete album.dataset.selected
+ else
+ album.dataset.selected = "1"
+ }
+
+ static albumsFilter(e) {
+ const q = e.target.value.toLowerCase()
+ for (const album of Album.albums) {
+ const match = album.data.albumName.toLowerCase().includes(q)
+ album.toggleVisibility(match)
+ }
+ }
+
+ static getSelected() {
+ const s = []
+ for (const album of Album.albums)
+ if (album.element.dataset.selected)
+ s.push(album.data.id)
+ return s
+ }
+
+ static submitSelected() {
+ apiConnector.updateAlbums(Album.getSelected())
+ }
+
+ static async initAlbums(albumsPageContainer) {
+ Album.albumsContainer = albumsPageContainer.querySelector("#albums-container")
+ Album.albumTemplate = albumsPageContainer.querySelector("#album-template")
+ const albumSearch = albumsPageContainer.querySelector("#album-search")
+ const albumsSubmit = albumsPageContainer.querySelector("#albums-submit")
+
+ // create albums
+ const albumsResponse = await apiConnector.fetchAlbums()
+ if (!albumsResponse.length)
+ return false
+
+ for (const res of albumsResponse)
+ new Album(res)
+
+ // album selection
+ Album.albumsContainer.addEventListener("click", Album.albumSelect)
+ albumSearch.addEventListener("input", Album.albumsFilter)
+ albumsSubmit.addEventListener("click", Album.submitSelected)
+
+ return true
+ }
+}
+
+export default Album.initAlbums
diff --git a/src/client/src/connector.js b/src/client/src/connector.js
new file mode 100644
index 0000000..9568dc8
--- /dev/null
+++ b/src/client/src/connector.js
@@ -0,0 +1,92 @@
+import io from "socket.io-client"
+
+class APIConnector {
+ constructor(url) {
+ this.url = url ?? ""
+ this.socket = io(url)
+
+ this.assetIndex = 0
+ this.movement = 0
+ this.assets = null
+ this.currentAsset = null
+ this.seekCallbacks = []
+
+ this.socket.on("seek", e => {
+ this.assetIndex = e.asset_index
+ this.movement = e.movement
+ this.assets = e.assets
+ this.currentAsset = e.current_asset
+ for (const cb of this.seekCallbacks)
+ cb()
+ })
+
+ this.downloadAnchor = document.createElement("a")
+ this.downloadAnchor.classList = "hidden"
+ document.body.appendChild(this.downloadAnchor)
+ }
+
+ fetch(endpoint, c) {
+ return fetch(this.url + "/api" + endpoint, c ?? {})
+ .then(response => {
+ if (!response.ok)
+ throw new Error(`HTTP error! Status: ${response.status}`)
+ return response.json()
+ })
+ .then(data => {
+ return data
+ })
+ .catch(error => {
+ console.error("Fetch error:", error)
+ return null
+ })
+ }
+
+ async assetDownload(key) {
+ const filename = await this.assetFileName(key)
+ fetch(this.assetFullsizeSrc(key))
+ .then(response => {
+ if (!response.ok)
+ throw new Error(`HTTP error! Status: ${response.status}`)
+ return response.blob()
+ })
+ .then(blob => {
+ const blobUrl = URL.createObjectURL(blob)
+ this.downloadAnchor.href = blobUrl
+ this.downloadAnchor.download = filename
+
+ this.downloadAnchor.click()
+ URL.revokeObjectURL(blobUrl)
+ })
+ .catch(error => {
+ console.error("Fetch error:", error)
+ return null
+ })
+ }
+
+ post(endpoint, body) {
+ return this.fetch(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ }
+
+ seek(increment) {
+ this.socket.emit("seek", increment)
+ }
+
+ fetchAlbums() { return this.fetch("/albums") }
+ fetchConfig() { return this.fetch("/config") }
+ updateAlbums(albums) { return this.post("/albums/update", albums) }
+ updateConfig(config) { return this.post("/config/update", config) }
+
+ albumSrc(key) { return `${this.url}/api/redirect/albums/${key}` }
+
+ assetPreviewSrc(key) { return `${this.url}/api/asset/${key}` }
+ assetThumbnailSrc(key) { return `${this.url}/api/asset/${key}/thumbnail` }
+ assetFullsizeSrc(key) { return `${this.url}/api/asset/${key}/fullsize` }
+ assetFileName(key) { return this.fetch(`/asset/${key}/filename`).then(d => d.filename) }
+}
+
+const apiConnector = new APIConnector(window.location.origin)
+export default apiConnector
diff --git a/src/client/src/icons.js b/src/client/src/icons.js
new file mode 100644
index 0000000..529d509
--- /dev/null
+++ b/src/client/src/icons.js
@@ -0,0 +1,19 @@
+import "./icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg"
+import "./icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg"
+import "./icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg"
+import "./icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg"
+import "./icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg"
+import "./icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg"
+import "./icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg"
+import "./icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg"
+import "./icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg"
+import "./icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg"
+import "./icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg"
+import "./icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg"
+import "./icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg"
+import "./icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg"
+import "./icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg"
+import "./icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg"
+import "./icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg"
+import "./icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg"
+import "./icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg"
diff --git a/src/client/src/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..5e389dc
--- /dev/null
+++ b/src/client/src/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M482-160q-134 0-228-93t-94-227v-7l-64 64-56-56 160-160 160 160-56 56-64-64v7q0 100 70.5 170T482-240q26 0 51-6t49-18l60 60q-38 22-78 33t-82 11Zm278-161L600-481l56-56 64 64v-7q0-100-70.5-170T478-720q-26 0-51 6t-49 18l-60-60q38-22 78-33t82-11q134 0 228 93t94 227v7l64-64 56 56-160 160Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..06d30f0
--- /dev/null
+++ b/src/client/src/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M500-600q-11 0-17-10t0-20l117-203q8-13 20-15.5t26 4.5q69 31 122 86.5T850-632q5 13-1.5 22.5T828-600H500Zm-143 49L237-759q-8-13-4-26t16-21q48-34 106.5-54T480-880q15 0 33 1.5t30 3.5q14 2 18.5 12t-2.5 22L392-551q-6 10-17.5 10T357-551ZM114-400q-11 0-20-7.5T83-426q-2-11-2.5-24t-.5-30q0-63 20.5-125.5T164-724q11-14 25.5-14t22.5 15l168 293q6 10-.5 20T362-400H114Zm200 284q-66-32-121-87t-83-125q-5-13 2-22.5t21-9.5h326q11 0 17 10t0 20L361-129q-8 13-20 17t-27-4Zm166 36q-14 0-31.5-1.5T418-85q-14-2-19-12t2-22l165-288q6-10 20-10t20 10l118 205q7 11 4.5 24T712-154q-46 34-107.5 54T480-80Zm271-155L581-530q-6-10 .5-20t17.5-10h247q11 0 20 7.5t11 18.5q2 11 2.5 24t.5 30q0 63-20.5 126.5T796-235q-8 11-23 11.5T751-235Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..028526b
--- /dev/null
+++ b/src/client/src/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="m424-296 282-282-56-56-226 226-114-114-56 56 170 170Zm56 216q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..dd46e14
--- /dev/null
+++ b/src/client/src/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M791-56 567-280H280v-287L56-791l56-57 736 736-57 56ZM360-360h127L360-487v127Zm320-33-80-80v-127H473l-80-80h287v287ZM200-200v80q-33 0-56.5-23.5T120-200h80Zm-80-80v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm160 480v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 480v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80q33 0 56.5 23.5T840-760h-80Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..e9ef3c9
--- /dev/null
+++ b/src/client/src/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M480-320 280-520l56-58 104 104v-326h80v326l104-104 56 58-200 200ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..1ff79ee
--- /dev/null
+++ b/src/client/src/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..12e0802
--- /dev/null
+++ b/src/client/src/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..aa7aece
--- /dev/null
+++ b/src/client/src/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M560-200v-560h160v560H560Zm-320 0v-560h160v560H240Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..95ead34
--- /dev/null
+++ b/src/client/src/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h480q33 0 56.5 23.5T800-800v640q0 33-23.5 56.5T720-80H240Zm40-160h400L545-420 440-280l-65-87-95 127Zm160-280 100-60 100 60v-280H440v280Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..e26dccc
--- /dev/null
+++ b/src/client/src/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M240-120q-17 0-28.5-11.5T200-160v-40h-80q-33 0-56.5-23.5T40-280v-440q0-33 23.5-56.5T120-800h720q33 0 56.5 23.5T920-720v440q0 33-23.5 56.5T840-200h-80v40q0 17-11.5 28.5T720-120H240Zm-40-240h560L580-600 440-420 340-540 200-360Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..22dd0af
--- /dev/null
+++ b/src/client/src/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M320-200v-560l440 280-440 280Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/removefill.sh b/src/client/src/icons/removefill.sh
new file mode 100755
index 0000000..399e8e4
--- /dev/null
+++ b/src/client/src/icons/removefill.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+# Google Material Icons -- complete default settings
+sed -Ei 's/ ?fill="#[0-9a-fA-F]{6}"//' *.svg
+for svg in *.svg; do
+ echo "import \"./icons/$svg\""
+done
diff --git a/src/client/src/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..1d95298
--- /dev/null
+++ b/src/client/src/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..3a6618e
--- /dev/null
+++ b/src/client/src/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M280-280v-400h400v400H280Zm80-80h240v-240H360v240ZM200-200v80q-33 0-56.5-23.5T120-200h80Zm-80-80v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm80-160h-80q0-33 23.5-56.5T200-840v80Zm80 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80q0 33-23.5 56.5T760-120Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80q33 0 56.5 23.5T840-760h-80Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..92240e4
--- /dev/null
+++ b/src/client/src/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm112-260q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..911d24e
--- /dev/null
+++ b/src/client/src/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm480-280q17 0 28.5-11.5T720-760q0-17-11.5-28.5T680-800q-17 0-28.5 11.5T640-760q0 17 11.5 28.5T680-720Zm0 520ZM200-480Zm480-280Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..ba0282f
--- /dev/null
+++ b/src/client/src/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M660-240v-480h80v480h-80Zm-440 0v-480l360 240-360 240Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..ea45ca0
--- /dev/null
+++ b/src/client/src/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M220-240v-480h80v480h-80Zm520 0L380-480l360-240v480Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..17fb6dd
--- /dev/null
+++ b/src/client/src/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="m380-300 280-180-280-180v360ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
new file mode 100644
index 0000000..41b5681
--- /dev/null
+++ b/src/client/src/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="m644-428-58-58q9-47-27-88t-93-32l-58-58q17-8 34.5-12t37.5-4q75 0 127.5 52.5T660-500q0 20-4 37.5T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z"/></svg> \ No newline at end of file
diff --git a/src/client/src/index.js b/src/client/src/index.js
new file mode 100644
index 0000000..7d7c9db
--- /dev/null
+++ b/src/client/src/index.js
@@ -0,0 +1,29 @@
+import "@fontsource/overpass"
+import "@fontsource/overpass/700.css"
+import "@fontsource/overpass/500.css"
+import "./style.css"
+import Page from "./pages.js"
+import "./icons.js"
+import initSlides from "./slides.js"
+import initAlbums from "./albums.js"
+import initSettings from "./settings.js"
+
+const slideshow = new Page(document.querySelector("#slideshow"), ["", "/slideshow"], initSlides)
+const albums = new Page(document.querySelector("#albums"), ["/albums"], initAlbums)
+const settings = new Page(document.querySelector("#settings"), ["/settings"], initSettings)
+
+window.addEventListener("popstate", Page.pathnameCallback)
+Page.pathnameCallback() /* run after all pages are registered */
+
+/* add event listeners for anchor elements in footer */
+function softRedirect(e) {
+ e.preventDefault()
+ let a = e.target
+ if (a === null) return
+ while (a !== null && a.tagName !== "A")
+ a = a.parentElement
+ if (a === null) return
+ Page.softRedirect(a.href)
+}
+
+document.querySelector("#menu").addEventListener("click", softRedirect)
diff --git a/src/client/src/pages.js b/src/client/src/pages.js
new file mode 100644
index 0000000..e2199a4
--- /dev/null
+++ b/src/client/src/pages.js
@@ -0,0 +1,54 @@
+const menu = document.querySelector("#menu")
+
+export default class Page {
+ static pages = {}
+ static currentPage = null
+
+ static pathnameCallback() {
+ const path = window.location.pathname.replace(/\/$/, "")
+ const page = Page.pages[path]
+
+ if (!page)
+ throw new Error(`Path '${path}' does not exist`)
+
+ if (Page.currentPage)
+ Page.currentPage.setVisible(false)
+
+ page.setVisible(true)
+ Page.currentPage = page
+ }
+
+ static softRedirect(path) {
+ window.history.pushState({}, "", path)
+ Page.pathnameCallback()
+ }
+
+ constructor(pageContainer, endpoints, f_initialize) {
+ for (const endpoint of endpoints)
+ Page.pages[endpoint] = this
+
+ this.pageContainer = pageContainer
+ this.endpoints = endpoints
+ this.initialize = f_initialize
+ this.visible = false
+ this.initialized = false
+ }
+
+ async setVisible(visible) {
+ this.pageContainer.classList.toggle("hidden!", !visible)
+ this.visible = visible
+ if (this.visible) {
+ /* initialize page */
+ if (!this.initialized && this.initialize)
+ this.initialized = await this.initialize(this.pageContainer)
+
+ /* set selected attribute on the link */
+ for (const a of menu.querySelectorAll("a")) {
+ if (this.endpoints.includes(a.getAttribute("href")))
+ a.dataset.selected = "1"
+ else
+ delete a.dataset.selected
+ }
+ }
+ }
+}
diff --git a/src/client/src/settings.js b/src/client/src/settings.js
new file mode 100644
index 0000000..fd8bdad
--- /dev/null
+++ b/src/client/src/settings.js
@@ -0,0 +1,16 @@
+import apiConnector from "./connector.js"
+
+export default async function initSettings(settingsPageContainer) {
+ const inputList = Array.from(settingsPageContainer.querySelectorAll("[name]"))
+ const inputs = Object.fromEntries(inputList.map(e => [e.name, e]))
+ const currentConfig = await apiConnector.fetchConfig()
+
+ for (const [name, value] of Object.entries(currentConfig))
+ if (inputs[name])
+ inputs[name].value = value
+
+ settingsPageContainer.querySelector("#settings-submit").addEventListener("click", e => {
+ e.preventDefault()
+ apiConnector.updateConfig(Object.fromEntries(inputList.map(el => [el.name, el.type === "number" ? parseFloat(el.value) : el.value])))
+ })
+}
diff --git a/src/client/src/slides.js b/src/client/src/slides.js
new file mode 100644
index 0000000..a0d58ac
--- /dev/null
+++ b/src/client/src/slides.js
@@ -0,0 +1,137 @@
+import Flickity from "flickity"
+import "flickity/dist/flickity.min.css"
+import apiConnector from "./connector.js"
+
+class Slides {
+ constructor(slidesContainer) {
+ /* previous selected index */
+ this.selectedIndex = 0
+ this.assetIndex = 0
+
+ this.slidesContainer = slidesContainer
+
+ /* append 11 cells to carousel */
+ const carousel = this.slidesContainer.querySelector("#slideshow-carousel")
+ const cellTemplate = this.slidesContainer.querySelector("#carousel-cell-template")
+ this.cells = []
+ for (let i = 0; i < 11; i++)
+ carousel.appendChild(cellTemplate.content.cloneNode(true))
+
+ /* initialize slides */
+ this.flickity = new Flickity(carousel, {
+ wrapAround: true,
+ prevNextButtons: false,
+ pageDots: false,
+ resize: true,
+ setGallerySize: false,
+ })
+
+ this.flickity.on("scroll", progress => { this.scroll(progress) })
+ this.flickity.on("staticClick", (e, pointer, cellElement, cellIndex) => { this.staticClick(e, pointer, cellElement, cellIndex) })
+ this.flickity.on("dragEnd", () => { this.seek() })
+ this.initImages()
+
+ /* initialize seek buttons */
+ const seekPrevButton = this.slidesContainer.querySelector("#prev-slide")
+ const seekNextButton = this.slidesContainer.querySelector("#next-slide")
+
+ seekPrevButton.addEventListener("click", () => { this.flickity.previous() ; this.seek() })
+ seekNextButton.addEventListener("click", () => { this.flickity.next() ; this.seek() })
+
+ /* initialize seek callback */
+ apiConnector.seekCallbacks.push(c => { this.seekCallback() })
+
+ /* initialize top controls */
+ const assetDownloadButton = this.slidesContainer.querySelector("#download")
+
+ assetDownloadButton.addEventListener("click", () => { apiConnector.assetDownload(apiConnector.currentAsset) })
+ }
+
+ seek() {
+ // this is just like calculating movement in lazycachelist.py
+ // gets the min of the absolute values and returns signed value
+ const increment = [
+ this.flickity.selectedIndex - this.selectedIndex, // no list wrap
+ this.flickity.selectedIndex - this.flickity.cells.length, // wrap backwards (0 -> -1)
+ this.flickity.cells.length - this.selectedIndex, // wrap forwards (-1 -> 0)
+ ].reduce((key, v) => Math.abs(v) < Math.abs(key) ? v : key)
+ this.selectedIndex = this.flickity.selectedIndex
+ this.assetIndex += increment
+ apiConnector.seek(increment)
+ }
+
+ seekCallback() {
+ let i
+ if (this.assetIndex !== apiConnector.assetIndex) {
+ this.assetIndex = apiConnector.assetIndex
+ i = apiConnector.movement
+
+ for (; i > 0; i--) this.flickity.next()
+ for (; i < 0; i++) this.flickity.previous()
+ this.selectedIndex = this.flickity.selectedIndex
+ }
+
+ // load new imgs
+ // TODO need to make the 11 cells a constant somehow
+ for (i = 0; i < this.flickity.cells.length; i++) {
+ const x = (i + this.selectedIndex + 6) % this.flickity.cells.length
+ const e = this.flickity.cells[x].element
+ const img = e.firstElementChild
+ img.src = apiConnector.assetPreviewSrc(apiConnector.assets[i])
+ }
+ }
+
+ /* Flickity function for scrolling to ensure next and prev pics are always
+ * visible and to transition between states */
+ scroll(progress) {
+ const normalizedProgress = progress / (1 / (this.flickity.cells.length-1))
+ const liveSelectedIndex = Math.round(normalizedProgress)
+ const localizedProgress = normalizedProgress - liveSelectedIndex
+
+ const prevSelectedCell = this.flickity.cells.at((liveSelectedIndex-1) % this.flickity.cells.length).element
+ const liveSelectedCell = this.flickity.cells.at((liveSelectedIndex ) % this.flickity.cells.length).element
+ const nextSelectedCell = this.flickity.cells.at((liveSelectedIndex+1) % this.flickity.cells.length).element
+
+ const prevSelectedImage = prevSelectedCell.firstElementChild
+ const liveSelectedImage = liveSelectedCell.firstElementChild
+ const nextSelectedImage = nextSelectedCell.firstElementChild
+
+ const prevMargin = prevSelectedCell.clientWidth - prevSelectedImage.clientWidth
+ const liveMargin = liveSelectedCell.clientWidth - liveSelectedImage.clientWidth
+ const nextMargin = nextSelectedCell.clientWidth - nextSelectedImage.clientWidth
+
+ liveSelectedImage.style.marginLeft = liveMargin / 2 + "px"
+ nextSelectedImage.style.marginLeft = Math.max(0, Math.min(nextMargin, nextMargin * (localizedProgress))) + "px"
+ prevSelectedImage.style.marginLeft = prevMargin - Math.max(0, Math.min(prevMargin, prevMargin * localizedProgress * -1)) + "px" // TODO clean this
+ }
+
+ /* jump to clicked on slide */
+ staticClick(e, pointer, cellElement, cellIndex) {
+ this.flickity.select(cellIndex)
+ this.seek()
+ }
+
+ /* make sure images have correct margin when loaded since scroll function
+ * depends on them being loaded */
+ positionImageStatic(img) {
+ const i = parseInt(img.dataset.index)
+ if (i == this.flickity.selectedIndex)
+ img.style.marginLeft = (img.parentElement.clientWidth - img.clientWidth) / 2 + "px"
+ else if ((i + 1) % this.flickity.cells.length == this.flickity.selectedIndex)
+ img.style.marginLeft = img.parentElement.clientWidth - img.clientWidth + "px"
+ }
+
+ imageLoaded(e) { this.positionImageStatic(e.target) }
+ initImages() {
+ const imgs = this.slidesContainer.querySelectorAll("#slideshow-carousel img")
+ for (let i = 0; i < imgs.length; i++) {
+ const img = imgs[i]
+ img.dataset.index = i
+ img.addEventListener("load", e => { this.imageLoaded(e) })
+ if (img.complete)
+ this.positionImageStatic(img)
+ }
+ }
+}
+
+export default slidesContainer => { new Slides(slidesContainer); return true }
diff --git a/src/client/src/style.css b/src/client/src/style.css
new file mode 100644
index 0000000..552b080
--- /dev/null
+++ b/src/client/src/style.css
@@ -0,0 +1,48 @@
+@import "tailwindcss";
+@source "../index.html";
+
+:root {
+ --font-sans: "Overpass", sans-serif;
+ @apply scheme-dark
+}
+
+.svg-btn {
+ @apply bg-none border-0 p-0 cursor-pointer
+}
+
+.nav-btn {
+ @apply flex items-center justify-center no-underline w-full gap-4 p-3 rounded-full
+ hover:text-blue-300 hover:fill-blue-300
+ data-selected:text-blue-300 data-selected:fill-blue-300
+ data-selected:bg-gray-900
+}
+
+.carousel-cell {
+ @apply h-full w-[70vw] mx-3 md:w-[80vw] md:mx-6
+}
+.carousel-img {
+ @apply align-middle h-full max-w-full object-contain
+}
+
+.album {
+ @apply flex relative h-40 gap-6 p-3 cursor-pointer
+ border-y border-slate-800
+ data-selected:bg-slate-950 data-selected:hover:bg-slate-900
+ hover:bg-gray-900
+}
+
+.rounded-fieldset {
+ @apply border border-gray-500 rounded-2xl p-6 flex flex-col gap-4
+}
+.fieldset-header {
+ @apply font-bold text-blue-300 fill-blue-300 text-xl
+}
+.settings-label {
+ @apply font-medium text-blue-200
+}
+.rounded-input {
+ @apply rounded-2xl bg-zinc-800 p-4
+}
+.rounded-btn {
+ @apply rounded-full w-fit bg-blue-300 px-4 py-2 text-black fill-black font-medium cursor-pointer flex gap-1
+}
diff --git a/src/server/__main__.py b/src/server/__main__.py
new file mode 100644
index 0000000..508a13a
--- /dev/null
+++ b/src/server/__main__.py
@@ -0,0 +1,20 @@
+import argparse
+
+from .flaskapi import app, socketio
+from .settings import Config
+from .manager import PixMan
+
+
+def main():
+ p = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+ p.add_argument("--fullscreen", help="start viewer window in fullscreen", action="store_true")
+ p.add_argument("--config", type=str, help="set config file path", default="config.json")
+ p.add_argument("--host", type=str, help="set web interface host", default="0.0.0.0")
+ p.add_argument("--port", type=int, help="set web interface port", default=8080)
+ args = p.parse_args()
+
+ PixMan.initialize(args, app, socketio)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/server/flaskapi.py b/src/server/flaskapi.py
new file mode 100644
index 0000000..6b910fa
--- /dev/null
+++ b/src/server/flaskapi.py
@@ -0,0 +1,90 @@
+from flask import Flask, Blueprint, request, send_from_directory, send_file, abort, redirect, jsonify
+from flask_socketio import SocketIO, emit
+from flask_cors import CORS
+from engineio.async_drivers import threading # For pyinstaller
+
+from .manager import PixMan
+from .settings import Config
+
+
+static_folder=PixMan().static_dir
+app = Flask(__name__, static_folder=static_folder, static_url_path="/")
+api = Blueprint("api", __name__)
+socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*") # NOTE debug, async_mode for pyinstaller
+CORS(api, origins="*") # NOTE debug
+
+
+@app.route("/")
+@app.route("/slideshow")
+@app.route("/albums")
+@app.route("/settings")
+def home():
+ return send_from_directory(static_folder, "index.html")
+
+
+@socketio.on("seek")
+def seek(increment):
+ if not (display := PixMan().display):
+ return {}
+ display.queue.put(lambda: display.seek(increment))
+ while not display.queue.empty():
+ pass
+ return { "imageIndex": display.current_texture_index }
+
+
+@api.route("/albums/update", methods=["POST"])
+def albums_update():
+ return { "success": PixMan().update_config({ "album_list": request.json }) }
+
+@api.route("/albums")
+def albums_get():
+ if not (ic := PixMan().immich_connector):
+ return {}
+ keys = [ "albumName", "albumThumbnailAssetId", "id", "startDate", "endDate", "assetCount", "shared", ]
+ selected_albums = PixMan().config.album_list
+ return [{
+ key: album.get(key, None) for key in keys
+ } | { "selected": album["id"] in selected_albums } for album in ic.load_all_albums() if album["assetCount"] ]
+
+
+@api.route("/asset/<key>/filename")
+def get_asset_name(key):
+ if not (ic := PixMan().immich_connector):
+ return {}
+ # TODO ensure getting actual album thumb
+ name = ic.load_image_filename(key)
+ if name is None:
+ abort(400)
+ return { "filename": name }
+
+
+@api.route("/asset/<key>/fullsize", defaults={ "size": "fullsize" })
+@api.route("/asset/<key>/thumbnail", defaults={ "size": "thumbnail" })
+@api.route("/asset/<key>", defaults={ "size": "preview" })
+def get_asset(key, size):
+ if not (ic := PixMan().immich_connector):
+ return {}
+ # TODO ensure getting actual album thumb
+ image_data, mimetype = ic.load_image(key, size=size)
+ if image_data is None:
+ abort(400)
+ return send_file(image_data, mimetype=mimetype)
+
+
+@api.route("/redirect/<path:path>")
+def immich_redirect(path):
+ if not (ic := PixMan().immich_connector):
+ return {}
+ return redirect(f"{ic.server_url}/{path}")
+
+
+@api.route("/config/update", methods=["POST"])
+def config_update():
+ return { "success": PixMan().update_config(request.json) }
+
+@api.route("/config")
+def config_get():
+ return jsonify(PixMan().config)
+
+
+app.register_blueprint(api, url_prefix="/api")
diff --git a/src/server/immich.py b/src/server/immich.py
new file mode 100644
index 0000000..d5c5fd1
--- /dev/null
+++ b/src/server/immich.py
@@ -0,0 +1,64 @@
+import requests
+from io import BytesIO
+from queue import Queue
+
+from .texture import ImageTexture
+from .manager import PixMan
+
+
+class ImmichConnector:
+ def __init__(self):
+ config = PixMan().config
+ self.server_url = config.immich_url.removesuffix("/")
+ self.api_key = config.immich_api_key
+ self.texture_load_queue = Queue()
+
+ def _request(self, endpoint):
+ if not self.server_url:
+ return None
+ return requests.get(f"{self.server_url}/api/{endpoint}", headers={ "x-api-key": self.api_key })
+
+ def load_all_albums(self):
+ response = self._request("albums")
+ if not response or response.status_code != 200: return
+
+ data = response.json()
+ return data
+
+ def load_album_assets(self, key):
+ response = self._request(f"albums/{key}")
+ if not response or response.status_code != 200: return
+
+ data = response.json()
+ return data["assets"]
+
+ def load_image(self, key, size="preview"):
+ response = self._request(f"assets/{key}/thumbnail?size={size}")
+ if not response or response.status_code != 200: return None, None
+
+ image_data = BytesIO(response.content)
+ mimetype = response.headers.get("Content-Type")
+ return image_data, mimetype
+
+ def load_image_filename(self, key):
+ response = self._request(f"assets/{key}")
+ if not response or response.status_code != 200: return None, None
+
+ data = response.json()
+ return data["originalFileName"]
+
+ def load_texture_async(self, texture_list, image_texture):
+ self.texture_load_queue.put((texture_list, image_texture))
+
+ def idle(self):
+ size = "preview" # TODO
+ while True:
+ texture_list, image_texture = self.texture_load_queue.get()
+ if not texture_list.index_in_cache_range(image_texture.asset_index) or texture_list.void:
+ continue # Texture was never loaded so it doesn't need to be free'd
+ image_data, _ = self.load_image(image_texture.asset_key, size)
+ image_texture.initialize(image_data)
+
+ def validate_connection(self):
+ # TODO
+ return True
diff --git a/src/server/lazycachelist.py b/src/server/lazycachelist.py
new file mode 100644
index 0000000..c224a8c
--- /dev/null
+++ b/src/server/lazycachelist.py
@@ -0,0 +1,170 @@
+from dataclasses import dataclass, asdict
+
+from .texture import ImageTextureImmichAsset
+from .manager import PixMan
+
+@dataclass
+class Album:
+ id: str
+ range_start: int
+ range_end: int
+ assets_list: list[str] = None
+
+ @property
+ def assets_count(self):
+ return end - start
+
+
+@dataclass
+class CallbackStateData:
+ asset_index: int
+ movement: int
+ assets: list[str]
+ current_asset: str
+
+ @classmethod
+ def from_lctl(cls, l):
+ si = (l.asset_index - 5) % l.asset_count
+ ei = (l.asset_index + 6) % l.asset_count
+ sa = l.assets[si:ei] if si < ei else l.assets[si:] + l.assets[:ei]
+ assets = [ a["id"] for a in sa ]
+ return cls(
+ asset_index=l.asset_index,
+ movement=l.last_movement,
+ assets=assets,
+ current_asset=assets[5],
+ )
+
+
+class LazyCachingTextureList():
+ def __init__(self, album_ids, max_cache_items=100, change_callback=None):
+ self.immich_connector = PixMan().immich_connector
+ self.void = False
+ assert max_cache_items >= 20, "Minimum cache items is 20" # Double small radius
+
+ # Ring buffer
+ self.max_cache_items = max_cache_items
+ self.radius_small = 10
+ self.cache_items_behind = 0
+ self.cache_items_ahead = 0
+ self.cache_index = 0
+ self.asset_index = 0
+ self.asset_count = 0
+ self.last_movement = 0
+
+ self.cached_items = 0
+
+ self.album_keys = album_ids
+ # TODO simplify album handling, dont need classes, etc.
+ self.albums = self._get_albums()
+ self.assets = self._get_album_assets()
+
+ self.change_callback = change_callback
+
+ @property
+ def max_cache_items(self):
+ return self.cache_length
+
+ @max_cache_items.setter
+ def max_cache_items(self, max_cache_items):
+ self.cache_length = max_cache_items
+ self.cache = [None] * max_cache_items
+ self.radius_large = int(max_cache_items / 2)
+
+ def free(self):
+ self.void = True
+
+ def index_in_cache_range(self, index):
+ index_range_low = (self.asset_index - self.cache_items_behind) % self.asset_count
+ index_range_high = (self.asset_index + self.cache_items_ahead ) % self.asset_count
+ if index_range_low > index_range_high:
+ return index_range_low <= index or index <= index_range_high
+ return index_range_low <= index <= index_range_high
+
+ def _get_albums(self):
+ albums = []
+ self.asset_count = i = 0
+ albums_info = self.immich_connector.load_all_albums()
+ for album_info in albums_info:
+ id = album_info["id"]
+ if id not in self.album_keys:
+ continue
+ asset_count = album_info["assetCount"]
+ albums.append(Album(id, i, i + asset_count))
+ i += asset_count + 1
+ self.asset_count += asset_count
+ return albums
+
+ def _get_album_assets(self):
+ assets = []
+ for album in self.albums:
+ assets += self.immich_connector.load_album_assets(album.id)
+ return assets
+
+ def _fill_cache(self, current_radius, final_radius, step):
+ if current_radius >= final_radius:
+ return current_radius
+
+ for i in range(current_radius * step, final_radius * step, step):
+ cache_index = (self.cache_index + i) % self.cache_length
+ asset_index = (self.asset_index + i) % self.asset_count
+ if self.cache[cache_index]:
+ self.cache[cache_index].free() # Since this is a ring buffer, textures outside of range can just get free'd here
+ asset = self.assets[asset_index]
+ tex = ImageTextureImmichAsset(asset, asset_index)
+ self.immich_connector.load_texture_async(self, tex)
+ self.cache[cache_index] = tex
+ self.cached_items += 1
+
+ return max(current_radius, final_radius)
+
+ def _update_cache_get_item(self, asset_index):
+ prev_asset_index = self.asset_index
+ self.asset_index = asset_index % self.asset_count
+ #if prev_asset_index == asset_index:
+ # return self.cache[self.cache_index]
+ # Movement is the distance between the previous and current index in the assets list
+ # Since the list wraps around, fastest method is just to get the abs min of the 3 cases
+ movement = min(
+ asset_index - prev_asset_index, # No list wrap
+ asset_index - self.asset_count, # Wrap backwards (0 -> -1)
+ self.asset_count - prev_asset_index, # Wrap forwards (-1 -> 0)
+ key=abs)
+ self.last_movement = movement
+ self.cache_index = (self.cache_index + movement) % self.cache_length
+
+ ahead = max(0, self.cache_items_ahead - movement)
+ behind = max(0, self.cache_items_behind + movement)
+
+ if ahead + behind > self.cache_length:
+ if movement < 0:
+ ahead += movement
+ else:
+ behind -= movement
+
+ #print("AHEAD/BEHIND/CACHE_I/ASSET_I/MOVEMENT:", ahead, behind, self.cache_index, self.asset_index, movement)
+ # TODO if ahead is 0 then clear queue
+
+ ahead = self._fill_cache(ahead, self.radius_small, +1) # Fill small radius ahead of cache_index
+ behind = self._fill_cache(behind, self.radius_small, -1) # Fill small radius behind cache_index
+ ahead = self._fill_cache(ahead, self.radius_large, +1) # Fill large radius ahead of cache_index
+
+ self.cache_items_ahead = ahead
+ self.cache_items_behind = behind
+
+ # Perform callback
+ if prev_asset_index != asset_index and self.change_callback:
+ self.change_callback(asdict(CallbackStateData.from_lctl(self)))
+
+ return self.cache[self.cache_index]
+
+ def __len__(self):
+ return self.asset_count
+
+ def __getitem__(self, index):
+ if not self.asset_count:
+ raise IndexError("Index out of bounds")
+ i = index % self.asset_count
+ if abs(index) > i:
+ raise IndexError("Index out of bounds")
+ return self._update_cache_get_item(index)
diff --git a/src/server/manager.py b/src/server/manager.py
new file mode 100644
index 0000000..02363b2
--- /dev/null
+++ b/src/server/manager.py
@@ -0,0 +1,127 @@
+import os
+import sys
+import copy
+from threading import Thread
+from pathlib import Path
+
+
+class PixMan:
+ _instance = None
+ _initialized = False
+
+ def __new__(cls, *args, **kwargs):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ else:
+ assert not args and not kwargs, f"Singleton {cls.__name__} cannot be called with arguments more than once"
+ return cls._instance
+
+ def __init__(self):
+ pass
+
+ @classmethod
+ def initialize(cls, args, app, socketio):
+ assert not cls._initialized, "Already initialized"
+ if cls._initialized:
+ return
+ cls._initialized = True
+ self = cls()
+
+ self.args = args
+ self.app = app
+ self.socketio = socketio
+ self.immich_connector = None
+ self.texture_list = None
+ self.display = None
+ self.t_flask = None
+ self.t_idle_download = None
+
+ self.configfile = self.args.config
+ self.config = Config.load(self.configfile) if os.path.exists(self.configfile) else Config()
+
+ self.init_web(self.args.host, self.args.port)
+ self.update_config(self.config, replace=True)
+
+ def init_web(self, host, port):
+ self.t_flask = Thread(target=self.app.run, kwargs={ "host": host, "port": port })
+ self.t_flask.start()
+
+ def init_window(self):
+ # Initialize immich connector
+ self.immich_connector = ImmichConnector()
+ if not self.immich_connector.validate_connection():
+ self.immich_connector = None
+ return
+
+ # Initialize texture list
+ self.update_textures()
+
+ # Begin downloading images
+ self.t_idle_download = Thread(target=self.immich_connector.idle, daemon=True)
+ self.t_idle_download.start()
+
+ # Create display
+ self.display = PixDisplay(self.texture_list)
+ self.display.main(fullscreen=self.args.fullscreen)
+ self.die()
+
+ def update_textures(self):
+ if self.texture_list:
+ self.texture_list.free()
+ change_callback = lambda d: self.socketio.emit("seek", d)
+ self.texture_list = LazyCachingTextureList(self.config.album_list, max_cache_items=self.config.max_cache_assets, change_callback=change_callback)
+ if self.display:
+ self.display.update_textures(self.texture_list)
+
+ def update_config(self, config, replace=False):
+ oldconfig = copy.deepcopy(self.config)
+ if replace:
+ self.config = config
+ else:
+ self.config.update(**config)
+
+ if self.display:
+ self.display.update_config()
+
+ if oldconfig.album_list != self.config.album_list:
+ self.update_textures()
+ elif self.texture_list:
+ self.texture_list.max_cache_items = self.config.max_cache_assets
+
+ # If all goes well
+ self.config.save(self.configfile)
+
+ # Initialize window if immich parameters are valid
+ if self.config.immich_url and self.config.immich_api_key and not self.display:
+ self.init_window()
+
+ return True
+
+ def die(self):
+ # Join threads and exit
+ self.t_flask.join()
+ self.t_idle_download.join()
+ sys.exit(0)
+
+ @property
+ def frozen(self):
+ # For pyinstaller
+ # https://api.arcade.academy/en/latest/tutorials/bundling_with_pyinstaller/index.html#handling-data-files
+ if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
+ return True
+ return False
+
+ @property
+ def static_dir(self):
+ user_assets = os.environ.get("IMMICH_FRAME_STATIC_WEB_ASSETS")
+ if user_assets:
+ return Path(user_assets)
+ if self.frozen:
+ return Path(sys._MEIPASS) / "dist"
+ return Path("../../dist")
+
+
+from .lazycachelist import LazyCachingTextureList
+from .window import PixDisplay
+from .immich import ImmichConnector
+from .settings import Config
diff --git a/src/server/renderer.py b/src/server/renderer.py
new file mode 100644
index 0000000..52572b1
--- /dev/null
+++ b/src/server/renderer.py
@@ -0,0 +1,172 @@
+import pygame
+import numpy as np
+from OpenGL.GL import *
+from typing import Protocol
+
+class ImageRenderer:
+ def __init__(self):
+ # Setup shader and quad
+ self.shader = self._init_shader()
+ self.vao = self._init_quad()
+
+ # Get uniform locations from the shader
+ self.uTransform = glGetUniformLocation(self.shader, "uTransform")
+ self.uAlpha = glGetUniformLocation(self.shader, "uAlpha")
+
+ # Use the shader program and set default texture unit to 0
+ glUseProgram(self.shader)
+ glUniform1i(glGetUniformLocation(self.shader, "uTexture"), 0)
+
+ # Setup transition
+ self.transition = None
+
+ def set_transition(self, transition_cls):
+ self.transition = transition_cls(self)
+
+ @staticmethod
+ def compile_shader(source, shader_type):
+ shader = glCreateShader(shader_type) # Create a new shader object
+ glShaderSource(shader, source) # Attach the shader source code
+ glCompileShader(shader) # Compile the shader
+ if not glGetShaderiv(shader, GL_COMPILE_STATUS): # Check if compilation succeeded
+ raise RuntimeError(glGetShaderInfoLog(shader).decode()) # Raise error with log if failed
+ return shader
+
+ @staticmethod
+ def _init_shader():
+ vertex_src = """
+ #version 300 es
+ precision mediump float; // Precision for float variables (mandatory for ES)
+ layout (location = 0) in vec2 aPos; // Vertex position
+ layout (location = 1) in vec2 aTexCoord; // Texture coordinates
+ uniform mat4 uTransform; // Transformation matrix
+ out vec2 TexCoord; // Output texture coordinate
+
+ void main() {
+ gl_Position = uTransform * vec4(aPos, 0.0, 1.0); // Apply transformation
+ TexCoord = aTexCoord; // Pass tex coord to fragment shader
+ }
+ """
+ fragment_src = """
+ #version 300 es
+ precision mediump float; // Precision for float variables (mandatory for ES)
+ in vec2 TexCoord; // Interpolated texture coordinates
+ out vec4 FragColor; // Final fragment color
+ uniform sampler2D uTexture; // Texture sampler
+ uniform float uAlpha; // Global alpha for transparency
+
+ void main() {
+ vec4 texColor = texture(uTexture, TexCoord); // Sample texture
+ FragColor = vec4(texColor.rgb, texColor.a * uAlpha); // Apply alpha blending
+ }
+ """
+
+ # Compile and link shaders into a program
+ vs = ImageRenderer.compile_shader(vertex_src, GL_VERTEX_SHADER)
+ fs = ImageRenderer.compile_shader(fragment_src, GL_FRAGMENT_SHADER)
+ prog = glCreateProgram()
+ glAttachShader(prog, vs)
+ glAttachShader(prog, fs)
+ glLinkProgram(prog)
+ return prog
+
+ @staticmethod
+ def _init_quad():
+ # Define a full-screen quad with positions and texture coordinates
+ quad = np.array([
+ -1, -1, 0, 0, # Bottom-left
+ 1, -1, 1, 0, # Bottom-right
+ -1, 1, 0, 1, # Top-left
+ 1, 1, 1, 1, # Top-right
+ ], dtype=np.float32)
+
+ # Create and bind a Vertex Array Object
+ vao = glGenVertexArrays(1)
+ vbo = glGenBuffers(1)
+ glBindVertexArray(vao)
+ glBindBuffer(GL_ARRAY_BUFFER, vbo)
+ glBufferData(GL_ARRAY_BUFFER, quad.nbytes, quad, GL_STATIC_DRAW)
+
+ # Setup vertex attributes: position (location = 0)
+ glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 16, ctypes.c_void_p(0))
+ glEnableVertexAttribArray(0)
+
+ # Setup vertex attributes: texture coordinates (location = 1)
+ glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 16, ctypes.c_void_p(8))
+ glEnableVertexAttribArray(1)
+ return vao
+
+ def draw_image(self, tex, win_w, win_h, alpha):
+ if not tex.initialized:
+ return
+ glUseProgram(self.shader)
+ glBindVertexArray(self.vao)
+ # FIXME check if tex.id is None
+ glBindTexture(GL_TEXTURE_2D, tex.id)
+
+ # Calculate aspect ratios
+ img_aspect = tex.width / tex.height
+ win_aspect = win_w / win_h
+
+ # Calculate scaling factors to preserve image aspect ratio
+ if img_aspect > win_aspect:
+ sx = 1.0
+ sy = win_aspect / img_aspect
+ else:
+ sx = img_aspect / win_aspect
+ sy = 1.0
+
+ # Create transformation matrix for aspect-ratio-correct rendering
+ transform = np.array([
+ [sx, 0, 0, 0],
+ [0, sy, 0, 0],
+ [0, 0, 1, 0],
+ [0, 0, 0, 1],
+ ], dtype=np.float32)
+
+ # Pass transformation and alpha to the shader
+ glUniformMatrix4fv(self.uTransform, 1, GL_FALSE, transform)
+ glUniform1f(self.uAlpha, alpha)
+
+ # Draw the textured quad
+ glDrawArrays(GL_TRIANGLE_STRIP, 0, 4)
+
+ def draw_static(self, tex, win_w, win_h, alpha):
+ # Set the background color to black
+ glClearColor(0.0, 0.0, 0.0, 1.0)
+ glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
+
+ self.draw_image(tex, win_w, win_h, alpha)
+
+ pygame.display.flip()
+
+ def draw_transition(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration, reversed):
+ assert self.transition, "No transition has been set"
+
+ # Set the background color to black
+ glClearColor(0.0, 0.0, 0.0, 1.0)
+ glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
+
+ self.transition.draw(tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration, reversed)
+
+ pygame.display.flip()
+
+
+class Transition(Protocol):
+ def __init__(self, renderer):
+ self.renderer = renderer
+
+ def draw(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration, reversed):
+ pass
+
+
+class TransitionMix(Transition):
+ def draw(self, tex_start, tex_end, win_w, win_h, delta_time, transition_time, transition_duration, reversed):
+ # Update alpha value for fade effect
+ alpha = transition_time / transition_duration
+ if alpha > 1.0:
+ alpha = 1.0
+
+ # Draw the images on top of one another
+ self.renderer.draw_image(tex_start, win_w, win_h, 1 - alpha) # TODO instead of decreasing alpha, draw transparent letterboxes
+ self.renderer.draw_image(tex_end, win_w, win_h, alpha)
diff --git a/src/server/settings.py b/src/server/settings.py
new file mode 100644
index 0000000..ce76e82
--- /dev/null
+++ b/src/server/settings.py
@@ -0,0 +1,32 @@
+from dataclasses import dataclass, asdict, field
+import json
+
+
+@dataclass
+class Config:
+ # Immich server
+ immich_url: str = ""
+ immich_api_key: str = ""
+ # Display
+ image_duration: float = 10.0
+ transition_duration: float = 0.5
+ max_framerate: float = 30.0
+ auto_transition: bool = True
+ display_size: str = "preview" # 'fullsize', 'preview', 'thumbnail'
+ # Cache
+ max_cache_assets: int = 100
+ # Albums data
+ album_list: list[str] = field(default_factory=list)
+
+ @classmethod
+ def load(cls, filepath):
+ with open(filepath, "r") as fp:
+ return cls(**json.load(fp))
+
+ def save(self, filepath):
+ with open(filepath, "w") as fp:
+ json.dump(asdict(self), fp, indent=2)
+
+ def update(self, **config):
+ for key, value in config.items():
+ setattr(self, key, value)
diff --git a/src/server/texture.py b/src/server/texture.py
new file mode 100644
index 0000000..3a56b5f
--- /dev/null
+++ b/src/server/texture.py
@@ -0,0 +1,67 @@
+from OpenGL.GL import *
+from OpenGL.GLUT import *
+from OpenGL.GLU import *
+from PIL import Image, ExifTags
+
+
+class ImageTexture:
+ def __init__(self, image_source, exif=None):
+ self.id = None
+ img = Image.open(image_source)
+ self.exif = exif or self.get_exif(img)
+ img = self.handle_orientation(img, self.exif)
+ img = img.convert("RGBA") # Ensure the image is in RGBA mode
+ self.width = img.width
+ self.height = img.height
+ self._img_data = img.tobytes("raw", "RGBA", 0, -1) # Convert image data to bytes
+ self.initialized = True
+
+ def gl_init(self):
+ if self.id:
+ return
+ #glPixelStorei(GL_UNPACK_ALIGNMENT, 1)
+ self.id = glGenTextures(1)
+ glBindTexture(GL_TEXTURE_2D, self.id)
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, self.width, self.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, self._img_data)
+ #glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, img.width, img.height, 0, GL_RGB, GL_UNSIGNED_BYTE, img_data)
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
+ del self._img_data # No longer needed, clear mem
+
+ #print(f"Loaded texture {self.id}")
+
+ def free(self):
+ if self.id is None:
+ del self._img_data
+ else:
+ glBindTexture(GL_TEXTURE_2D, 0) # Unbind
+ glDeleteTextures([self.id])
+
+ @staticmethod
+ def get_exif(img):
+ return { ExifTags.TAGS[k]: v for k, v in img.getexif().items() if k in ExifTags.TAGS }
+
+ @staticmethod
+ def handle_orientation(img, exif):
+ orientation = exif.get("Orientation", 1) if exif is not None else 1
+
+ if orientation == 3: return img.rotate(180, expand=True)
+ if orientation == 6: return img.rotate(270, expand=True)
+ if orientation == 8: return img.rotate( 90, expand=True)
+
+ return img
+
+
+class ImageTextureImmichAsset(ImageTexture):
+ def __init__(self, asset, asset_index):
+ self.initialized = False
+ self.asset_index = asset_index
+ self.asset_key = asset["id"]
+ self.exif = asset.get("exif", None)
+
+ # So that free can work
+ self.id = None
+ self._img_data = None
+
+ def initialize(self, image_source):
+ super().__init__(image_source, self.exif)
diff --git a/src/server/window.py b/src/server/window.py
new file mode 100644
index 0000000..4597c54
--- /dev/null
+++ b/src/server/window.py
@@ -0,0 +1,154 @@
+import pygame
+from pygame.locals import *
+from OpenGL.GL import *
+from queue import Queue
+
+from .renderer import ImageRenderer, TransitionMix
+from .manager import PixMan
+
+class PixDisplay:
+ def __init__(self, textures):
+ self.screen = None
+ self.last_time = 0
+ self.start_time = 0
+ self.image_time = 0
+ self.paused = False
+ self.textures = textures
+ self.current_texture_index = 0
+ self.renderer = None
+ self.win_w = 0
+ self.win_h = 0
+
+ self.transition_reverse = False
+ self.text_prev = None
+ self.tex = None
+
+ self._force_redraw = False
+ self.queue = Queue()
+
+ self.update_config()
+
+ def update_config(self):
+ config = PixMan().config
+ self.max_framerate = config.max_framerate
+ self.image_duration = config.image_duration
+ self.transition_duration = config.transition_duration
+ self.auto_transition = config.auto_transition
+
+ def update_textures(self, textures):
+ self.textures = textures
+ self.current_texture_index = 0
+
+ def increment_texture_index(self, increment):
+ self.transition_reverse = increment < 0
+
+ self.tex_prev = self.textures[self.current_texture_index]
+ self.current_texture_index = (self.current_texture_index + increment) % len(self.textures)
+ self.tex = self.textures[self.current_texture_index]
+
+ if not self.tex.initialized or not self.tex_prev.initialized:
+ return
+
+ # Ensure textures are initialized for opengl
+ self.tex_prev.gl_init()
+ self.tex.gl_init()
+
+ # Main display function
+ def display(self):
+ # Calculate timings
+ alive_time = pygame.time.get_ticks() / 1000
+ delta_time = alive_time - self.last_time
+ self.last_time = alive_time
+
+ if not self.tex or not self.tex.initialized or not self.tex.id:
+ if self.textures.asset_count > 0:
+ self.increment_texture_index(0)
+ # Draw black window if no textures are available
+ if not self.tex or not self.tex.id:
+ glClearColor(0.0, 0.0, 0.0, 1.0)
+ glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
+ pygame.display.flip()
+ return
+
+ # Run queue events
+ while not self.queue.empty():
+ f = self.queue.get() # Get the task and its data
+ f()
+
+ # Progress image time
+ if not self.paused:
+ self.image_time += delta_time
+
+ # Get window size
+ old_win_w, old_win_h = self.win_w, self.win_h
+ self.win_w, self.win_h = self.screen.get_size()
+
+ # Draw static image except during a transition
+ if self.image_time < self.image_duration:
+ # Avoid unforced-redraw unless window size has changed
+ if self._force_redraw or self.win_w != old_win_w or self.win_h != old_win_h:
+ self.renderer.draw_static(self.tex, self.win_w, self.win_h, 1.0)
+ self._force_redraw = False
+ return
+
+ # Start drawing transition once image_time >= image_duration
+ if self.auto_transition:
+ self.increment_texture_index(1)
+ self.auto_transition = False
+
+ transition_time = self.image_time - self.image_duration
+
+ self.renderer.draw_transition(self.tex_prev, self.tex, self.win_w, self.win_h, delta_time, transition_time, self.transition_duration, self.transition_reverse)
+
+ if transition_time >= self.transition_duration:
+ self.image_time = 0
+ self.auto_transition = True
+
+ def seek(self, increment):
+ self.auto_transition = False
+ self.increment_texture_index(increment)
+ self.image_time = self.image_duration
+
+ # Initialization and main loop
+ def main(self, fullscreen=False):
+ # Initialize the window
+ pygame.init()
+ self.screen = pygame.display.set_mode((0, 0), DOUBLEBUF | OPENGL | (FULLSCREEN if fullscreen else 0))
+ pygame.mouse.set_visible(False)
+
+ # Set up the OpenGL viewport and projection
+ glEnable(GL_TEXTURE_2D)
+ glMatrixMode(GL_PROJECTION)
+ glLoadIdentity()
+ glOrtho(-1, 1, -1, 1, -1, 1)
+ glMatrixMode(GL_MODELVIEW)
+
+ # Enable alpha blending
+ glEnable(GL_BLEND)
+ glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
+
+ # Setup renderer and slide timing
+ self.renderer = ImageRenderer()
+ self.renderer.set_transition(TransitionMix)
+ self.image_time = 0
+ self.last_time = 0
+
+ # Run display
+ clock = pygame.time.Clock()
+ while True:
+ for event in pygame.event.get():
+ if event.type == QUIT:
+ pygame.quit()
+ return
+ elif event.type == KEYDOWN:
+ # Quit with escape or ctrl+q
+ if event.key == pygame.K_ESCAPE or event.key == pygame.K_q and event.mod & pygame.KMOD_CTRL:
+ pygame.quit()
+ return
+ # Seek with left/right arrow
+ elif event.key == pygame.K_LEFT:
+ self.seek(-1)
+ elif event.key == pygame.K_RIGHT:
+ self.seek(+1)
+ self.display()
+ clock.tick(self.max_framerate)