summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTim Keller <tjk@tjkeller.xyz>2025-12-20 12:20:32 -0600
committerTim Keller <tjk@tjkeller.xyz>2025-12-20 12:20:32 -0600
commite4ff6c4028c33feac03bb32bbd0eda84d8138a8f (patch)
tree600eec12afdec59c4ced8a7356932766192ad4c2
parented9b272afedf93e3b5b4fba77e5213d07b976653 (diff)
downloadimmich-frame-e4ff6c4028c33feac03bb32bbd0eda84d8138a8f.tar.xz
immich-frame-e4ff6c4028c33feac03bb32bbd0eda84d8138a8f.zip
replace original client with static sveltekit site. Transition and refactor code for sveltekit. Add support for view transitions api.
-rw-r--r--src/client/index.html138
-rw-r--r--src/client/src/albums.js87
-rw-r--r--src/client/src/app.html12
-rw-r--r--src/client/src/icons.js19
-rw-r--r--src/client/src/index.js29
-rw-r--r--src/client/src/lib/SVGSprite.svelte6
-rw-r--r--src/client/src/lib/SVGSpriteButton.svelte61
-rw-r--r--src/client/src/lib/app.css22
-rw-r--r--src/client/src/lib/connector.ts (renamed from src/client/src/connector.js)23
-rw-r--r--src/client/src/lib/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg)0
-rwxr-xr-xsrc/client/src/lib/icons/removefill.sh (renamed from src/client/src/icons/removefill.sh)0
-rw-r--r--src/client/src/lib/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/lib/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg (renamed from src/client/src/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg)0
-rw-r--r--src/client/src/pages.js54
-rw-r--r--src/client/src/routes/+layout.svelte98
l---------src/client/src/routes/+page.svelte1
-rw-r--r--src/client/src/routes/albums/+page.svelte77
-rw-r--r--src/client/src/routes/settings/+page.svelte103
-rw-r--r--src/client/src/routes/slideshow/+page.svelte149
-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/client/svelte.config.ts21
39 files changed, 572 insertions, 529 deletions
diff --git a/src/client/index.html b/src/client/index.html
deleted file mode 100644
index dcecf89..0000000
--- a/src/client/index.html
+++ /dev/null
@@ -1,138 +0,0 @@
-<!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
deleted file mode 100644
index 0ac9195..0000000
--- a/src/client/src/albums.js
+++ /dev/null
@@ -1,87 +0,0 @@
-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/app.html b/src/client/src/app.html
new file mode 100644
index 0000000..b1faee9
--- /dev/null
+++ b/src/client/src/app.html
@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ %sveltekit.head%
+ <link rel="manifest" href="%sveltekit.assets%/manifest.json" />
+ </head>
+ <body data-sveltekit-preload-data="hover">
+ <div style="display: contents">%sveltekit.body%</div>
+ </body>
+</html>
diff --git a/src/client/src/icons.js b/src/client/src/icons.js
deleted file mode 100644
index 529d509..0000000
--- a/src/client/src/icons.js
+++ /dev/null
@@ -1,19 +0,0 @@
-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/index.js b/src/client/src/index.js
deleted file mode 100644
index 7d7c9db..0000000
--- a/src/client/src/index.js
+++ /dev/null
@@ -1,29 +0,0 @@
-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/lib/SVGSprite.svelte b/src/client/src/lib/SVGSprite.svelte
new file mode 100644
index 0000000..ff965c7
--- /dev/null
+++ b/src/client/src/lib/SVGSprite.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ let { sprite, ...rest }: { sprite: string } = $props()
+ const href = $derived(`/__spritemap#sprite-${sprite}`)
+</script>
+
+<svg {...rest}><use {href}></use></svg>
diff --git a/src/client/src/lib/SVGSpriteButton.svelte b/src/client/src/lib/SVGSpriteButton.svelte
new file mode 100644
index 0000000..a9c9fcb
--- /dev/null
+++ b/src/client/src/lib/SVGSpriteButton.svelte
@@ -0,0 +1,61 @@
+<script lang="ts">
+ import SVGSprite from "$lib/SVGSprite.svelte"
+
+ let {
+ sprite,
+ label,
+ href,
+ alt,
+ innerClass,
+ ...rest
+ }: {
+ sprite: string
+ label?: string
+ href?: string
+ alt?: string
+ innerClass?: string
+ } = $props()
+</script>
+
+<style>
+ @import "tailwindcss";
+
+ .selected-button, [aria-current=page] {
+ @apply text-blue-300 fill-blue-300 bg-gray-900;
+ }
+ /* TODO
+ [aria-current=page]::before {
+ view-transition-name: active-page;
+ }
+ */
+</style>
+
+{#snippet inner()}
+
+ <SVGSprite class={innerClass ?? "size-full"} {sprite} {alt}></SVGSprite>
+ {#if label}
+ <span class="max-[425px]:hidden">{label}</span>
+ {/if}
+{/snippet}
+
+{#if href}
+ <a
+ {...rest}
+ class={[
+ "flex items-center justify-center no-underline w-full gap-4 p-3",
+ "relative rounded-full hover:text-blue-300 hover:fill-blue-300",
+ // @ts-ignore
+ rest.class
+ ]}
+ {href}
+ >
+ {@render inner()}
+ </a>
+{:else}
+ <button
+ class="bg-none border-0 p-0 cursor-pointer relative"
+ {...rest}
+ >
+ {@render inner()}
+ </button>
+{/if}
diff --git a/src/client/src/lib/app.css b/src/client/src/lib/app.css
new file mode 100644
index 0000000..b88882b
--- /dev/null
+++ b/src/client/src/lib/app.css
@@ -0,0 +1,22 @@
+@import "tailwindcss";
+
+:root {
+ --font-sans: "Overpass", sans-serif;
+ @apply scheme-dark
+}
+
+.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/client/src/connector.js b/src/client/src/lib/connector.ts
index 9568dc8..0224438 100644
--- a/src/client/src/connector.js
+++ b/src/client/src/lib/connector.ts
@@ -1,5 +1,26 @@
import io from "socket.io-client"
+export interface Album {
+ id: string
+ albumThumbnailAssetId: string
+ albumName: string
+ assetCount: number
+ shared: boolean
+ selected: boolean
+}
+
+export interface Config {
+ immich_url: string
+ immich_api_key: string
+ image_duration: number
+ transition_duration: number
+ max_framerate: number
+ auto_transition: boolean
+ display_size: "fullsize" | "preview" | "thumbnail"
+ max_cache_assets: number
+ album_list?: string[]
+}
+
class APIConnector {
constructor(url) {
this.url = url ?? ""
@@ -88,5 +109,5 @@ class APIConnector {
assetFileName(key) { return this.fetch(`/asset/${key}/filename`).then(d => d.filename) }
}
-const apiConnector = new APIConnector(window.location.origin)
+const apiConnector = new APIConnector("http://192.168.1.182:8080/")
export default apiConnector
diff --git a/src/client/src/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
index 5e389dc..5e389dc 100644
--- a/src/client/src/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/cached_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg
index 06d30f0..06d30f0 100644
--- a/src/client/src/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/camera_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg
index 028526b..028526b 100644
--- a/src/client/src/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/check_circle_24dp_E3E3E3_FILL1_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
index dd46e14..dd46e14 100644
--- a/src/client/src/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/deselect_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
index e9ef3c9..e9ef3c9 100644
--- a/src/client/src/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/download_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
index 1ff79ee..1ff79ee 100644
--- a/src/client/src/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/image_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
index 12e0802..12e0802 100644
--- a/src/client/src/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/open_in_new_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
index aa7aece..aa7aece 100644
--- a/src/client/src/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/pause_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
index 95ead34..95ead34 100644
--- a/src/client/src/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/photo_album_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
index e26dccc..e26dccc 100644
--- a/src/client/src/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/photo_frame_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
index 22dd0af..22dd0af 100644
--- a/src/client/src/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/play_arrow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/removefill.sh b/src/client/src/lib/icons/removefill.sh
index 399e8e4..399e8e4 100755
--- a/src/client/src/icons/removefill.sh
+++ b/src/client/src/lib/icons/removefill.sh
diff --git a/src/client/src/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
index 1d95298..1d95298 100644
--- a/src/client/src/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/search_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
index 3a6618e..3a6618e 100644
--- a/src/client/src/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/select_all_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
index 92240e4..92240e4 100644
--- a/src/client/src/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/settings_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
index 911d24e..911d24e 100644
--- a/src/client/src/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/share_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
index ba0282f..ba0282f 100644
--- a/src/client/src/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/skip_next_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
index ea45ca0..ea45ca0 100644
--- a/src/client/src/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/skip_previous_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
index 17fb6dd..17fb6dd 100644
--- a/src/client/src/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/slideshow_24dp_FFFFFF_FILL1_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg b/src/client/src/lib/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
index 41b5681..41b5681 100644
--- a/src/client/src/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
+++ b/src/client/src/lib/icons/visibility_off_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg
diff --git a/src/client/src/pages.js b/src/client/src/pages.js
deleted file mode 100644
index e2199a4..0000000
--- a/src/client/src/pages.js
+++ /dev/null
@@ -1,54 +0,0 @@
-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/routes/+layout.svelte b/src/client/src/routes/+layout.svelte
new file mode 100644
index 0000000..d963f02
--- /dev/null
+++ b/src/client/src/routes/+layout.svelte
@@ -0,0 +1,98 @@
+<script lang="ts">
+ import { onNavigate } from "$app/navigation"
+ import "@fontsource/overpass"
+ import "@fontsource/overpass/700.css"
+ import "@fontsource/overpass/500.css"
+ import "$lib/app.css"
+ import SVGSpriteButton from "$lib/SVGSpriteButton.svelte"
+
+ let { children } = $props()
+
+ /* Endpoints */
+ const endpoints = [
+ { label: "Slideshow", sprite: "slideshow", href: "#/slideshow", isDefault: true },
+ { label: "Albums", sprite: "photo_album", href: "#/albums" },
+ { label: "Settings", sprite: "settings", href: "#/settings" },
+ ]
+
+ /* View transition */
+ let swipeTransitionReversed: boolean = $state(false)
+ let hashPath: string = $state(window.location.hash)
+ onNavigate((navigation: any) => {
+ const oldHashPath = hashPath
+ hashPath = window.location.hash
+
+ /* Check if browser supports view transitions api and whether an actual navigation has occurred */
+ if (!document.startViewTransition || oldHashPath == hashPath)
+ return
+
+ const orderedHashPaths = endpoints.map(e => e.href)
+ swipeTransitionReversed = orderedHashPaths.indexOf(oldHashPath) > orderedHashPaths.indexOf(hashPath)
+
+ /* Begin view transition */
+ return new Promise((resolve: Function) => {
+ document.startViewTransition(async () => {
+ resolve()
+ await navigation.complete
+ })
+ })
+ })
+</script>
+
+<svelte:head>
+ <!-- <link rel="icon" href={favicon} /> -->
+</svelte:head>
+
+<style>
+ @keyframes slide-from-right { from { transform: translateX(100dvw) } }
+ @keyframes slide-from-left { from { transform: translateX(-100dvw) } }
+ @keyframes slide-to-right { to { transform: translateX(100dvw) } }
+ @keyframes slide-to-left { to { transform: translateX(-100dvw) } }
+
+ footer { view-transition-name: navbar }
+
+ @media (prefers-reduced-motion: no-preference) {
+ :root::view-transition-old(root) {
+ animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
+ }
+ :root::view-transition-new(root) {
+ animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
+ }
+ :root:has(.swipe-transition-reversed)::view-transition-old(root) {
+ animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-right;
+ }
+ :root:has(.swipe-transition-reversed)::view-transition-new(root) {
+ animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-left;
+ }
+ }
+</style>
+
+<div
+ class="bg-black text-white fill-white h-dvh max-h-screen flex flex-col m-0 overflow-hidden"
+ class:swipe-transition-reversed={swipeTransitionReversed}
+>
+ <header></header>
+ <main class="my-4 overflow-hidden h-full flex flex-col">
+ {@render children?.()}
+ </main>
+ <!-- Bottom nav bar -->
+ <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">
+ {#snippet navButton(label: string, sprite: string, href: string, isDefault: boolean = false)}
+ {@const selected = href == hashPath || (isDefault && !window.location.hash)}
+ <SVGSpriteButton
+ {label}
+ {sprite}
+ {href}
+ innerClass="size-6"
+ aria-current={selected && "page"}
+ ></SVGSpriteButton>
+ {/snippet}
+ {#each endpoints as e}
+ {@render navButton(e.label, e.sprite, e.href, e.isDefault)}
+ {/each}
+ </div>
+ </div>
+ </footer>
+</div>
diff --git a/src/client/src/routes/+page.svelte b/src/client/src/routes/+page.svelte
new file mode 120000
index 0000000..ff71e47
--- /dev/null
+++ b/src/client/src/routes/+page.svelte
@@ -0,0 +1 @@
+slideshow/+page.svelte \ No newline at end of file
diff --git a/src/client/src/routes/albums/+page.svelte b/src/client/src/routes/albums/+page.svelte
new file mode 100644
index 0000000..9534307
--- /dev/null
+++ b/src/client/src/routes/albums/+page.svelte
@@ -0,0 +1,77 @@
+<script lang="ts">
+ import { onMount } from "svelte"
+ import SVGSprite from "$lib/SVGSprite.svelte"
+ import apiConnector, { type Album } from "$lib/connector"
+
+ let albums: Album[] = $state([])
+ //let filteredAlbums: Album[] = $derived()
+
+ function updateAlbums() {
+ apiConnector.updateAlbums(albums.filter((a: Album) => a.selected).map((a: Album) => a.id))
+ }
+
+ onMount(async () => {
+ albums = await apiConnector.fetchAlbums()
+ })
+</script>
+
+<div class="overflow-y-scroll">
+ <div class="px-4 m-auto max-w-3xl">
+ <!-- Header search bar -->
+ <div class="p-2 sticky top-0 z-20 bg-black">
+ <input class="rounded-input mx-auto mb-4 w-full" placeholder="Search your albums" />
+ <button
+ class="rounded-btn"
+ onclick={updateAlbums}
+ >
+ <SVGSprite sprite="check_circle" class="size-6"></SVGSprite>
+ Select
+ </button>
+ </div>
+ <!-- Albums -->
+ <div class="m-auto z-10">
+ {#each albums as album}
+ <button
+ class={[
+ "flex relative h-40 w-full gap-6 p-3 cursor-pointer border-y border-slate-800 text-left group",
+ album.selected ? "bg-slate-950 hover:bg-slare-900" : "hover:bg-gray-900",
+ ]}
+ onclick={() => album.selected = !album.selected}
+ >
+ <SVGSprite
+ alt="Select Album"
+ sprite="check_circle"
+ class={[
+ "opacity-0 self-center size-6",
+ album.selected ? "opacity-100 fill-blue-300" : "group-hover:opacity-50",
+ ]}
+ >
+ </SVGSprite>
+ <img
+ alt=""
+ src={apiConnector.assetThumbnailSrc(album.albumThumbnailAssetId)}
+ class="aspect-square object-cover rounded-2xl group-hover:shadow-md"
+ />
+ <div class="flex justify-between self-center text-lg">
+ <div>
+ <span class="font-bold group-hover:text-blue-300">{album.albumName}</span>
+ <div>
+ <span>{album.assetCount} items</span>
+ {#if album.shared}
+ <span>&bull; Shared</span>
+ {/if}
+ </div>
+ </div>
+ </div>
+ <a
+ href={apiConnector.albumSrc(album.id)}
+ target="_blank"
+ class="opacity-0 group-hover:opacity-100 absolute bottom-4 right-4"
+ >
+ <SVGSprite alt="View album in Immich" sprite="open_in_new" class="size-6"></SVGSprite>
+ </a>
+ </button>
+ {/each}
+ </div>
+ </div>
+</div>
diff --git a/src/client/src/routes/settings/+page.svelte b/src/client/src/routes/settings/+page.svelte
new file mode 100644
index 0000000..a0483a0
--- /dev/null
+++ b/src/client/src/routes/settings/+page.svelte
@@ -0,0 +1,103 @@
+<script lang="ts">
+ import apiConnector, { type Config } from "$lib/connector"
+ import { onMount } from "svelte"
+
+ let immich_url: string = $state()
+ let immich_api_key: string = $state()
+ let image_duration: number = $state()
+ let transition_duration: number = $state()
+ let max_framerate: number = $state()
+ let display_size: string = $state()
+ let max_cache_assets: number = $state()
+
+ function onsubmit(e: SubmitEvent) {
+ e.preventDefault()
+ const config: Config = {
+ immich_url,
+ immich_api_key,
+ image_duration,
+ transition_duration,
+ max_framerate,
+ display_size,
+ max_cache_assets,
+ }
+ apiConnector.updateConfig(config)
+ }
+
+ onMount(async () => {
+ const currentConfig: Config = await apiConnector.fetchConfig()
+ ;({
+ immich_url, immich_api_key, image_duration, transition_duration,
+ max_framerate, display_size, max_cache_assets,
+ } = currentConfig)
+ })
+</script>
+
+<div class="overflow-y-scroll">
+ <form
+ class="flex flex-col gap-4 m-auto max-w-3xl mx-auto px-4"
+ {onsubmit}
+ >
+ <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 for="immich_url" 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" id="immich_url" type="text" bind:value={immich_url} />
+ <div>
+ <label for="immich_api_key" class="settings-label">Immich API Key</label>
+ <p>Generate an API key in User Settings</p>
+ </div>
+ <input class="rounded-input" id="immich_api_key" type="text" bind:value={immich_api_key} />
+ </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 for="image_duration" class="settings-label">Image Duration</label>
+ <p>Number of seconds each image will be displayed.</p>
+ </div>
+ <input class="my-auto rounded-input" id="image_duration" type="number" step="0.1" bind:value={image_duration} />
+ <div>
+ <label for="transition_duration" 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" id="transition_duration" type="number" step="0.1" bind:value={transition_duration} />
+ <div>
+ <label for="max_framerate" 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" id="max_framerate" type="number" step="0.1" bind:value={max_framerate} />
+ <div>
+ <label for="display_size" 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" id="display_size" bind:value={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 for="max_cache_assets" 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" id="max_cache_assets" type="number" bind:value={max_cache_assets} />
+ </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>
diff --git a/src/client/src/routes/slideshow/+page.svelte b/src/client/src/routes/slideshow/+page.svelte
new file mode 100644
index 0000000..df2d352
--- /dev/null
+++ b/src/client/src/routes/slideshow/+page.svelte
@@ -0,0 +1,149 @@
+<script lang="ts">
+ import { onMount } from "svelte"
+ import type { Attachment } from "svelte/attachments"
+ import SVGSpriteButton from "$lib/SVGSpriteButton.svelte"
+ import Flickity from "flickity"
+ import "flickity/dist/flickity.min.css"
+ import apiConnector from "$lib/connector"
+
+ let selectedIndex: number
+ let assetIndex: number
+ let assetSrcs: string[] = $state(new Array(11).map(() => undefined)) /* 11 cells for carousel */
+
+ /* Initialize carousel */
+ let carousel: HTMLDivElement
+ let flickity: Flickity
+
+ /* Change image function */
+ function seek() {
+ if (!flickity)
+ return
+ // This is just like calculating movement in lazycachelist.py
+ // Gets the min of the absolute values and returns signed value
+ const increment = [
+ flickity.selectedIndex - selectedIndex, // No list wrap
+ flickity.selectedIndex - flickity.cells.length, // Wrap backwards (0 -> -1)
+ flickity.cells.length - selectedIndex, // Wrap forwards (-1 -> 0)
+ ].reduce((key, v) => Math.abs(v) < Math.abs(key) ? v : key)
+ selectedIndex = flickity.selectedIndex
+ assetIndex += increment
+ apiConnector.seek(increment)
+ }
+
+ /* Socket callback for seek signal -- load images */
+ apiConnector.seekCallbacks.push(() => {
+ if (!flickity)
+ return
+ if (assetIndex !== apiConnector.assetIndex) {
+ assetIndex = apiConnector.assetIndex
+ let i = apiConnector.movement
+ for (; i > 0; i--) flickity.next()
+ for (; i < 0; i++) flickity.previous()
+ selectedIndex = flickity.selectedIndex
+ }
+
+ // Load new assets
+ for (let i = 0; i < flickity.cells.length; i++) {
+ const x = (i + selectedIndex + 6) % flickity.cells.length
+ assetSrcs[x] = apiConnector.assetPreviewSrc(apiConnector.assets[i])
+ }
+ })
+
+ /* Make sure images have correct margin when loaded since scroll function
+ * depends on them being loaded */
+ function positionImageStatic(img: HTMLImageElement) {
+ if (!flickity)
+ return
+ const i = parseInt(img.dataset.index)
+ if (i == flickity.selectedIndex)
+ img.style.marginLeft = (img.parentElement.clientWidth - img.clientWidth) / 2 + "px"
+ else if ((i + 1) % flickity.cells.length == flickity.selectedIndex)
+ img.style.marginLeft = img.parentElement.clientWidth - img.clientWidth + "px"
+ }
+
+ const attachImg: Attachment = (img: HTMLImageElement) => {
+ img.addEventListener("load", () => positionImageStatic(img))
+ if (img.complete)
+ positionImageStatic(img)
+ }
+
+ onMount(() => {
+ flickity = new Flickity(carousel, {
+ wrapAround: true,
+ prevNextButtons: false,
+ pageDots: false,
+ resize: true,
+ setGallerySize: false,
+ })
+
+ /* Flickity function for scrolling to ensure next and prev pics are always
+ * visible and to transition between states */
+ flickity.on("scroll", progress => {
+ const normalizedProgress: number = progress / (1 / (flickity.cells.length-1))
+ const liveSelectedIndex: number = Math.round(normalizedProgress)
+ const localizedProgress: number = normalizedProgress - liveSelectedIndex
+
+ const prevSelectedCell: HTMLElement = (flickity.cells.at((liveSelectedIndex-1) % flickity.cells.length) as any).element
+ const liveSelectedCell: HTMLElement = (flickity.cells.at((liveSelectedIndex ) % flickity.cells.length) as any).element
+ const nextSelectedCell: HTMLElement = (flickity.cells.at((liveSelectedIndex+1) % flickity.cells.length) as any).element
+
+ const prevSelectedImage: HTMLImageElement = prevSelectedCell.firstElementChild as HTMLImageElement
+ const liveSelectedImage: HTMLImageElement = liveSelectedCell.firstElementChild as HTMLImageElement
+ const nextSelectedImage: HTMLImageElement = nextSelectedCell.firstElementChild as HTMLImageElement
+
+ const prevMargin: number = prevSelectedCell.clientWidth - prevSelectedImage.clientWidth
+ const liveMargin: number = liveSelectedCell.clientWidth - liveSelectedImage.clientWidth
+ const nextMargin: number = 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 */
+ flickity.on("staticClick", (e, pointer, cellElement, cellIndex) => {
+ flickity.select(cellIndex)
+ seek()
+ })
+
+ /* Seek whenever a touch event ends */
+ flickity.on("dragEnd", seek)
+ })
+
+ /* Share */
+ function shareCurrent() {
+
+ }
+
+ function downloadCurrent() {
+
+ }
+</script>
+
+<div class="overflow-hidden h-full flex flex-col">
+ <!-- Header share buttons -->
+ <div class="flex justify-end pe-[10vw] w-full absolute bg-linear-to-b from-black/40 z-20">
+ <div class="z-10">
+ <SVGSpriteButton alt="Share" sprite="share" class="p-3 size-13" onclick={shareCurrent}></SVGSpriteButton>
+ <SVGSpriteButton alt="Download" sprite="download" class="p-3 size-13" onclick={downloadCurrent}></SVGSpriteButton>
+ </div>
+ </div>
+ <!-- Carousel -->
+ <div class="h-full" bind:this={carousel}>
+ {#each assetSrcs as src}
+ <div class="h-full w-[70vw] mx-3 md:w-[80vw] md:mx-6">
+ <img alt="" class="align-middle h-full max-w-full object-contain" {src} {@attach attachImg} />
+ </div>
+ {/each}
+ </div>
+ <!-- Media controls -->
+ <div class="grid grid-cols-3 w-full max-w-3xl m-auto justify-items-center py-4">
+ <SVGSpriteButton alt="Previous Slide" sprite="skip_previous" class="size-12" onclick={() => { flickity.previous(); seek() }}></SVGSpriteButton>
+ {#if false}
+ <SVGSpriteButton alt="Pause" sprite="pause" class="size-12" onclick={() => {}}></SVGSpriteButton>
+ {:else}
+ <SVGSpriteButton alt="Pause" sprite="pause" class="size-12" onclick={() => {}}></SVGSpriteButton>
+ {/if}
+ <SVGSpriteButton alt="Next Slide" sprite="skip_next" class="size-12" onclick={() => { flickity.next(); seek() }}></SVGSpriteButton>
+ </div>
+</div>
diff --git a/src/client/src/settings.js b/src/client/src/settings.js
deleted file mode 100644
index fd8bdad..0000000
--- a/src/client/src/settings.js
+++ /dev/null
@@ -1,16 +0,0 @@
-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
deleted file mode 100644
index a0d58ac..0000000
--- a/src/client/src/slides.js
+++ /dev/null
@@ -1,137 +0,0 @@
-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
deleted file mode 100644
index 552b080..0000000
--- a/src/client/src/style.css
+++ /dev/null
@@ -1,48 +0,0 @@
-@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/client/svelte.config.ts b/src/client/svelte.config.ts
new file mode 100644
index 0000000..e6eb2e2
--- /dev/null
+++ b/src/client/svelte.config.ts
@@ -0,0 +1,21 @@
+import adapter from "@sveltejs/adapter-static"
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ kit: {
+ output: {
+ bundleStrategy: "inline",
+ },
+ router: {
+ type: "hash",
+ },
+ adapter: adapter({
+ // default options are shown. On some platforms
+ // these options are set automatically — see below
+ pages: "../../dist",
+ fallback: "index.html",
+ })
+ }
+}
+
+export default config