diff options
Diffstat (limited to 'src')
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">• 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) |
