-- 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 UNIT_F, AGO_F = 0.40, 0.24 -- the "Y" beside the big value, "ago" under it 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 this week', 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 = (photos.week and 'This week' or 'Today') .. (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 }, { 'ago', card.FONT_UI, AGO_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 -- The big value carries its unit the way a temperature carries its degree -- sign: a small "Y" top-right of the digits, "ago" under it on the -- baseline, both right-aligned in a column against the card edge. local right, base = inner.x + inner.w, inner.y + S * BIG_F card.font(cr, card.FONT_UI, S * AGO_F, false) local col_w = card.measure(cr, 'ago') card.rgba(cr, colors.label, 0.65) card.text_right(cr, right, base, 'ago') card.font(cr, card.FONT_HEAVY, S * UNIT_F, false) local unit_w, unit_h = card.measure(cr, 'Y') col_w = math.max(col_w, unit_w) card.font(cr, card.FONT_HEAVY, S * BIG_F, false) local _, big_h = card.measure(cr, big) card.rgba(cr, colors.body) card.text_right(cr, right - col_w - S * 0.08, base, big) card.font(cr, card.FONT_HEAVY, S * UNIT_F, false) card.text_right(cr, right, base - big_h + unit_h, 'Y') 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. 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