aboutsummaryrefslogtreecommitdiffstats
path: root/widgets/immich.lua
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-29 13:33:14 +0200
committerDanilo M. <danix@danix.xyz>2026-09-29 13:33:14 +0200
commit9624351d80ace747d3117fb22d6cbd00fce3eedf (patch)
treef9adc0a04445718d2d2b5e1b659b00fb8373c73c /widgets/immich.lua
parent272c5732b0bc932749f0e771de51ee49f6cebe5b (diff)
downloadconky-theme-udt-9624351d80ace747d3117fb22d6cbd00fce3eedf.tar.gz
conky-theme-udt-9624351d80ace747d3117fb22d6cbd00fce3eedf.zip
feat: add an Immich memories card with click and scroll
A centred card showing today's "N years ago" photos from Immich, one at a time on a 30-second rotation, with when each was taken and where. bin/immich-fetch.sh runs from conky's execi every 30 minutes: it asks /api/memories for today, converts each preview to PNG (Cairo loads nothing else) and writes an index with the year, local capture time and place. The place needs the key's asset.read permission and reads '--' without it. The key lives in ~/.config/udt/immich.env, like the weather key, because a GPG-backed store would raise a pinentry unattended. The dashboard now registers lua_mouse_hook and hands pointer events to the widget under the pointer. On this card, scrolling steps through the photos and a left click opens the photo in Immich's web UI and hides the dashboard so the browser is not stuck beneath it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'widgets/immich.lua')
-rw-r--r--widgets/immich.lua164
1 files changed, 164 insertions, 0 deletions
diff --git a/widgets/immich.lua b/widgets/immich.lua
new file mode 100644
index 0000000..17e0b02
--- /dev/null
+++ b/widgets/immich.lua
@@ -0,0 +1,164 @@
+-- Immich: today's "N years ago" memories, one photo at a time.
+--
+-- Everything comes from the cache bin/immich-fetch.sh writes: the photos as
+-- PNGs (Cairo loads nothing else) and an index with the year, when each was
+-- taken and, when the API key may read it, where. The card rotates through
+-- them on the wall clock, so it needs no state beyond the loaded surface.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+
+local DIR = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
+ .. '/udt/immich/'
+
+local ROTATE = 30 -- seconds per photo
+
+-- Photos stepped by scrolling, added to the wall-clock rotation. Kept across
+-- draws, and read by the click handler to open the photo on screen.
+local offset = 0
+local shown = nil -- { url, id } of the photo drawn last
+
+local M = {}
+
+-- The loaded photo. One surface held at a time: decoding a PNG every 2s draw
+-- would be wasted work, and holding every photo would be wasted memory.
+local loaded = { path = nil, surf = nil }
+
+local function surface(path)
+ if loaded.path == path then return loaded.surf end
+ if loaded.surf then cairo_surface_destroy(loaded.surf) end
+ local s = cairo_image_surface_create_from_png(path)
+ if cairo_surface_status(s) ~= CAIRO_STATUS_SUCCESS then
+ cairo_surface_destroy(s)
+ s = nil
+ end
+ loaded.path, loaded.surf = path, s
+ return s
+end
+
+-- The photo scaled to cover the band and clipped to its rounded rect, the way
+-- a cropped thumbnail reads: no letterbox bars for a portrait photo.
+local function draw_photo(cr, s, x, y, w, h, colors)
+ card.rounded_path(cr, x, y, w, h, 10)
+ if not s then
+ card.rgba(cr, colors.rule, 0.3)
+ cairo_fill(cr)
+ return
+ end
+ cairo_save(cr)
+ cairo_clip(cr)
+ local iw, ih = cairo_image_surface_get_width(s), cairo_image_surface_get_height(s)
+ local k = math.max(w / iw, h / ih)
+ cairo_translate(cr, x + (w - iw * k) / 2, y + (h - ih * k) / 2)
+ cairo_scale(cr, k, k)
+ cairo_set_source_surface(cr, s, 0, 0)
+ cairo_paint(cr)
+ cairo_restore(cr)
+end
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
+ local LABEL_F, BIG_F, ROW_F, SUB_F = 0.30, 1.0, 0.32, 0.26
+
+ local photos = data.immich_parse(data.slurp(DIR .. 'index.txt') or '')
+ if not photos then
+ card.notice(cr, inner, colors, 'MEMORIES', 'no immich data',
+ 'bin/immich-fetch.sh')
+ return
+ end
+ if #photos == 0 then
+ card.notice(cr, inner, colors, 'MEMORIES', 'no memories today',
+ os.date('%d/%m'))
+ return
+ end
+
+ local n = #photos
+ local i = (os.time() // ROTATE + offset) % n + 1
+ local p = photos[i]
+ shown = { url = photos.url, id = p.id }
+
+ local ago = p.year and (tonumber(os.date('%Y')) - p.year) or nil
+ local big = ago and tostring(ago) or '--'
+ local sub = (ago == 1 and 'year ago' or 'years ago')
+ .. (n > 1 and string.format(' %d/%d', i, n) or '')
+ local t = p.taken
+ local taken = t and string.format('%02d/%02d/%04d %02d:%02d',
+ t[3], t[2], t[1], t[4], t[5]) or '--'
+
+ -- Fluid type, sized so header and rows take at most ~40% of the card and
+ -- the photo absorbs the rest. The place is left out of the fit: its length
+ -- varies per photo, so it is truncated to the width instead of shrinking
+ -- the whole card's type every time a long place name comes round.
+ local groups = {
+ { { card.title_str(card.ICON.immich, 'MEMORIES'), card.FONT_MONO, LABEL_F },
+ { big, card.FONT_HEAVY, BIG_F } },
+ { { sub, card.FONT_UI, SUB_F } },
+ { { 'TAKEN ', card.FONT_MONO, ROW_F }, { taken, card.FONT_MONO, ROW_F } },
+ }
+ local S = clamp(math.min(inner.h * 0.40 / (1.5 + 2 * 0.66),
+ card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
+ local label_size, row_size = S * LABEL_F, S * ROW_F
+
+ card.font(cr, card.FONT_HEAVY, S * BIG_F, false)
+ card.rgba(cr, colors.body)
+ card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, big)
+
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.title(cr, inner.x, inner.y + label_size, card.ICON.immich, 'MEMORIES', colors)
+
+ card.font(cr, card.FONT_UI, S * SUB_F, false)
+ card.rgba(cr, colors.label, 0.65)
+ card.text(cr, inner.x, inner.y + label_size + S * SUB_F * 1.5, sub)
+
+ -- Two rows pinned to the bottom; the photo takes what lies between.
+ local step = row_size * 2.0
+ local rows_top = inner.y + inner.h - step * 2 + row_size * 0.4
+ local band_y = inner.y + S * BIG_F + row_size
+ draw_photo(cr, surface(DIR .. p.id .. '.png'), inner.x, band_y, inner.w,
+ rows_top - band_y - row_size * 0.4, colors)
+
+ card.font(cr, card.FONT_MONO, row_size, false)
+ local label_w = card.advance(cr, 'PLACE ')
+ local place = p.place or '--'
+ -- Cut by characters until it fits beside its label; truncate() keeps UTF-8
+ -- intact, which a place name with an accent needs.
+ local limit = utf8.len(place) or #place
+ while limit > 1 and (card.measure(cr, card.truncate(place, limit)))
+ > inner.w - label_w do
+ limit = limit - 1
+ end
+ place = card.truncate(place, limit)
+
+ local ey = rows_top + row_size
+ for _, r in ipairs({ { 'TAKEN', taken }, { 'PLACE', place } }) do
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, r[1])
+ card.rgba(cr, colors.value)
+ card.text_right(cr, inner.x + inner.w, ey, r[2])
+ ey = ey + step
+ end
+end
+
+-- Scroll steps through today's photos; a left click opens the one on screen
+-- in Immich and hides the dashboard, since the browser would otherwise open
+-- underneath the special workspace.
+--
+-- ponytail: a scroll shows at the next draw, up to update_interval (2s) later.
+-- Conky offers Lua no way to request a redraw.
+function M.mouse(event)
+ if event.type == 'mouse_scroll' then
+ offset = offset + (event.direction == 'up' and -1 or 1)
+ return true
+ end
+ if event.type == 'button_down' and event.button == 'left'
+ and shown and shown.url then
+ local url = shown.url .. '/photos/' .. shown.id
+ os.execute("xdg-open '" .. url:gsub("'", "'\\''") .. "' >/dev/null 2>&1 &")
+ os.execute([[hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null 2>&1 &]])
+ return true
+ end
+ return false
+end
+
+return M