Przejdź do treści
3 ramki działają w rodzinie

Wyślij mailem.
Pojawi się u Babci.

Rodzinna ramka zdjęć na tablecie albo telewizorze. Jeden adres mailowy dla całej rodziny. Bez aplikacji, bez logowania, bez sztuczek.

Ramki
3
Zdjęć
1100+
Miast pogody
5
Domowy kotek
14:32
18°
Jak to działa

Trzy kroki. Bez aplikacji.

Wszystko opiera się na zwykłym mailu. Nikt nie musi instalować żadnej aplikacji ani zakładać konta.

  1. 📸
    01

    Zrób zdjęcie

    Telefonem, aparatem, czymkolwiek. JPEG, PNG, HEIC z iPhone'a, krótki film MP4 — wszystko działa.

  2. 📧
    02

    Wyślij mailem

    Na fotomadrzak@gmail.com. Dopisz #babcia w temacie — trafi na ramkę u Babci. Bez tagu — Babcia sama wybierze gdzie.

  3. 🖼️
    03

    Pojawia się

    Na ramce w salonie, zwykle w pół minuty. Dołącza do rotacji slajdów — będzie się pokazywać razem z innymi.

💡 Wysyłasz do nas pierwszy raz? Zatwierdzimy Cię raz w ramce — kolejne maile lecą już prosto, bez czekania.

Dla rodziny

Wnuczek, ciocia, kuzynka —
każdy dorzuca.

Każdy w rodzinie ma własną skrzynkę mailową. To wszystko, czego potrzeba, żeby zdjęcie trafiło na ramkę.

Nowa wiadomość
Do: fotomadrzak@gmail.com
Temat: Wnuczek ze spaceru #babcia
Cześć Babciu! Posyłam zdjęcie z dzisiaj — Wojtek bardzo się ucieszył z huśtawki w parku 🌳
📸
🌳
😊
📷 Co mogę wysłać?

Zdjęcia z telefonu, aparatu, ze skanera — wszystko jedno. Również krótkie filmy i animowane GIF-y. Standardowy limit poczty (do ~25 MB na mail) wystarczy w codziennym użytku.

🏷️ Jak skierować na konkretną ramkę?

Dopisz tag do tematu maila:

  • #babcia  → ramka u Babci
  • #dziadek  → ramka u Dziadka
  • #wnuczek  → ramka u Wnuczka

Bez tagu — zdjęcie trafia do „Niezaadresowanych" w panelu, admin przypisze ręcznie.

🚪 Pierwszy raz wysyłam — co wtedy?

Twoje zdjęcia czekają chwilę na zatwierdzenie — żeby na ramkę u Babci nie wpadało nic od przypadkowych osób. Po jednorazowej akceptacji każdy kolejny mail leci od razu, bez czekania.

⏱️ Jak szybko zdjęcie się pokaże?

Zwykle pół minuty od wysłania. Mail dochodzi, zdjęcie się przygotowuje (niewielkie wersje na ramkę i miniaturki), trafia do rotacji slajdów — i już przy następnym pojawi się na ekranie u Babci.

Dla admina

Panel kontrolny
całej rodziny.

Jeden URL, sesja przez ciastko, kontrola nad każdą ramką, każdym nadawcą i każdym mailem.

foto.emecity.pl/panel/
Panel admina — lista ramek
🖼️ /panel/

Ramki

Lista ramek (Babcia, Dziadek...). Schedule dnia/nocy, audio preset, shuffle, pogoda 5 miast — wszystko per ramka.

📥 /panel/photos?status=pending

Poczekalnia

Zdjęcia od nieznanych nadawców czekają na zatwierdzenie. Jeden klik akceptuje sender + jego zdjęcie.

👥 /panel/senders

Nadawcy

Allowlista. Per nadawca: domyślna ramka, banowanie, statystyki przyjętych/odrzuconych.

📧 /panel/emails

Maile

Historia inboxu z dwóch kont IMAP (madrzak + Gmail). Reprocess maila ręcznie, zobacz błąd parsowania, dorzuć tag #slug.

🐱 /panel/mascot

Maskotka

Frazy które maskotka wyświetla na ramce: imieniny, daty, salwa serc dla Babci.

📺 /panel/apk

PIN do TV

6-cyfrowy kod 10 min albo link 24 h. Spróbuj poniżej ↓

Panel · Kod do TV

Kod, który wpisujesz na telewizorze

Generujesz krótki, sześciocyfrowy kod. Wpisujesz go na telewizorze albo nowym tablecie i ramka instaluje się sama. Kod jest aktywny 10 minut — potem znika, żeby nie leżał gdzieś bezczynnie.

Druga opcja: link ważny 24 godziny — wysyłasz go przez Messengera, ktoś klika i ramka się instaluje.

Ciekawi Cię co jest pod spodem? Stack i słownik pojęć są w sekcji Dla geeków

Urządzenia

Tablet w salonie.
Telewizor w sypialni.

Ten sam Capacitor APK obsługuje oba światy. Onboarding raz, potem urządzenie samo żyje — wybudza się rano, gaśnie wieczorem.

Lenovo TB305FU ze slideshow rodzinnej ramki
Tablet w salonie

Cały dzień non-stop

Pomiędzy 7:00 a 22:00 ramka chodzi nieprzerwanie — slajdy, filmy, pogoda, ciekawostki. W nocy gaśnie sama, rano wstaje sama. Dotknij ekran, a wybudzisz ją na 5 minut.

  • Pełny ekran, bez pasków systemowych — tylko zdjęcia
  • Niezawodne wybudzanie — nawet po głębokim uśpieniu w nocy
  • Instalacja krótkim kodem, bez sklepu Google
Sony BRAVIA z aplikacją Foto Emecity
Telewizor w sypialni

Pilot, kafelek w menu, gotowe

Ramka pojawia się w głównym menu telewizora obok Netflixa i YouTube. Klikasz pilotem — i już patrzysz na rodzinne zdjęcia z kanapy. Bez przewodów, bez magii.

  • Ta sama ramka co na tablecie — wystarczy raz zainstalować
  • Pilotem włączasz, pilotem usypiasz
  • Działa nawet z najbardziej upartymi modelami Sony
Smaczki

Drobne rzeczy, które
sprawiają, że ramka żyje.

Nie tylko slideshow — pogoda, muzyka, maskotka, news. Wszystko opcjonalne, wszystko per ramka.

🌤️

Pogoda u rodziny

Co kilka slajdów ramka pokazuje pogodę z pięciu polskich miast — żeby Babcia widziała, czy w Krakowie u wnuczki pada.

Dla rodziny rozsianej po Polsce
🎵

Muzyka w tle

Babcia lubi RMF Classic, Dziadek SomaFM. Każda ramka ma swoją stację — gra cicho w tle przez cały dzień, nocą milknie.

Coś dla każdego
🔀

Losowa kolejność

Zdjęcia nie lecą zawsze tak samo — co pewien czas tasujemy, żeby Dziadek nie nauczył się sekwencji na pamięć.

Każdy raz inaczej
🐱

Maskotka

Pływające serca, kwiaty, balony. Życzenia imieninowe wskakują akurat w dzień Babci. Plus stały rezydent — Whiskers.

Żywa, gada, świętuje
📰

Świat za oknem

Raz na jakiś czas mała karta z wiadomościami z Polski. Żeby ramka żyła nie tylko zdjęciami, ale też dniem.

Krótkie, bez czytania gazet
🌙

Sama wstaje, sama zasypia

Rano ramka się włącza, wieczorem gaśnie. Filmy puszczają się tylko za dnia. Godziny ustawiasz raz w panelu.

Bez codziennego klikania
📺

Pasek-stacja TV

„Babciu zadzwonię o 18:00", „AWARIA WODY DO 15:00" — krótki komunikat na samej górze ekranu, zielony / niebieski / czerwony pulsujący. Sam znika po zadanym czasie.

Komunikacja w 30 sekund

Aktualizacje bez podchodzenia

Zmieniasz godziny snu albo dodajesz miasto pogody w panelu — ramka zauważa sama w 5 minut. Nawet pełen update aplikacji idzie zdalnie, bez wstawania od biurka.

Telewizor z drugiej strony Polski
🖼️

Jedno zdjęcie, wiele ramek

Wnuczka na urodzinach trafia do Babci i Cioci jednym mailem — albo jednym kliknięciem w panelu. Bez powtórnej wysyłki, bez kopii pliku.

Rodzina jako sieć
📰

Przewijany news

Pasek u dołu ekranu, jak w telewizji: „Imieniny obchodzi Stanisław", „Sobota grill o 14:00", „Plan weekendu — kościół 10:00". Do 300 znaków, przewijany albo statyczny, na jedną ramkę lub na wszystkie naraz.

Drugi kanał obok komunikatu
⏸️

Pauza na ulubionym

Babcia patrzy na wnuczkę i chce się jej napatrzeć dłużej niż 30 sekund? Tap, pauza — slajd stoi do odwołania. Klawiszem na klawiaturze, pilotem, dotykiem.

Czas się zatrzymuje na życzenie

Świeże częściej

Mail od mamy z dziś rano pojawia się na ramce częściej niż zdjęcie z 2014 roku — przez tydzień. Ulubione (★) lecą podwójnie. Nadawcy z wyższym priorytetem dominują rotację, archiwum siedzi w tle.

Algorytm który zna rodzinę
Rezydent ramki

Whiskers, Burek, Mądrala, Skoczek.
Wybierasz, kto u Was mieszka.

Każda ramka ma swojego stałego rezydenta w rogu ekranu — postać, która siedzi tam codziennie. Drzemie, oddycha, czasem mrugnie. Te same animacje, które chodzą u nas na ramce — pokażemy poniżej, każdy z nich jest do wyboru w panelu.

Whiskers

cat

Drzemie w rogu od rana. Klasyk gatunku.

Burek

dog

Dobry kompan. Zasypia ze zmęczenia po spacerze.

Mądrala

owl

Czuwa w dzień, mruga okiem. Wszystko widziała.

Skoczek

bunny

Chwilę spokoju i już znów zerka na zegar.

Bambus

panda

Spokojny żywioł. Patrzy uważnie i chrupie liście.

Reksio

puppy

Pieseczek-łakomczuch. Macha ogonem i wita każdego.

Mruczek

kitty

Kociak-zawadiaka. Goni za własnym ogonem do skutku.

Trąbalski

jumbo

Słonik-dżentelmen. Macha trąbą i pamięta wszystko.

Pędzio

crocodile_on_scooter

Krokodyl na hulajnodze. Wpada, mruga okiem i już go nie ma.

Bryza

yacht

Jacht na spokojnej fali. Płynie cicho, bez pośpiechu.

Wodniaki

bunnies_in_boat

Króliczki w łódce. Wiosłują razem, śmieją się głośno.

  • 🛏️
    Zawsze tam jest

    Inne maskotki przelatują przez ekran. Rezydent zostaje — to wasz domownik.

  • 💤
    Wieczorem zasypia

    Razem z ramką, kiedy wybija godzina ciszy. Rano się przeciąga i wraca.

  • 🎚️
    Sterowanie z panelu

    Wybierasz zwierzaka, ustawiasz rozmiar (mały / średni / duży) i widoczność (40–100%).

  • 🔄
    Rotacja co 10 minut

    Włącz checkbox w panelu i rezydent zmienia się cyklicznie: kotek → piesek → sowa → króliczek → panda → pieseczek → kociak → słonik. Bez restartu, bez klikania. Babcia i Dziadek mają to samo zwierzę w tym samym 10-minutowym oknie.

  • 🪄
    Każda ramka — swój

    Babcia może mieć kotka, Dziadek psa, wnuczek sowę. Każdy ekran inny — chyba że włączysz rotację, wtedy razem.

💡 Animacje respektują ustawienia systemu — jeśli ktoś nie lubi ruchu na ekranie, rezydent po prostu nie przyciąga uwagi.
Bezpieczeństwo

Babcia ma spokój.
Nikt obcy się nie wepchnie.

Rodzinna ramka to też prywatność rodziny. Każda warstwa od maila po APK ma własny chwyt.

🔑

Klucz do ramki

Każda ramka ma swój własny klucz, jak zamek do drzwi. Otrzymujesz go raz, przy włączaniu — potem ramka pamięta sama.

📨

Powiadomienie, gdy ktoś nowy zagląda

Jeśli ramka Babci nagle otworzy się z innego miejsca niż jej salon — dostajesz mail. Spokój ducha bez wglądania co chwilę.

📌

Kod tylko z jednego urządzenia

Gdy generujesz krótki kod do telewizora — działa wyłącznie z tego konkretnego TV. Ktoś inny, nawet jakby go podejrzał, nic z nim nie zrobi — kod ma 10 minut ważności i działa tylko z TV, który jako pierwszy go użyje.

Drzwi tylko dla rodziny

Tylko zaproszone osoby mogą wysyłać zdjęcia. Niezaproszony adres trafia do poczekalni — Babcia decyduje, czy wpuścić.

💾

Codziennie kopia zapasowa

Każdej nocy zapisujemy wszystkie zdjęcia i ustawienia. Masz dwa tygodnie zapasu — gdyby coś poszło źle, zawsze jest gdzie wrócić.

🌍

Wszystko zostaje u nas

Zdjęcia nie wędrują do żadnego cudzego serwera. Lecą tylko z mailem prosto na nasz komputer, stamtąd na ramkę u Babci.

Szczegóły techniczne (klucze, hashe, oryginały vs. miniaturki) opisaliśmy w sekcji Dla geeków

Dla geeków

Co jest pod spodem.

Cała ramka to seria pragmatycznych decyzji — opisanych dla tych, których ciekawi nie tylko efekt, ale i to, jak dokładnie gra tu każda warstwa.

Stack

Backend
Node 20 ESM · Express 5 · better-sqlite3 (z online db.backup) · pino logger
Worker poczty
imapflow z IMAP IDLE × 3 konta paralelnie (foto@madrzak.pl, fotomadrzak@gmail.com, melafoto@madrzak.pl) · mailparser · ImageMagick CLI (HEIC za darmo dzięki libheif)
Frontend tabletu
React 19 + Vite 7 · vite-plugin-pwa (web fallback) · idb-keyval na localStorage
Frontend admina
React 19 + Vite 7 + React Router 7 · vanilla CSS z custom properties · zod walidacja
Strona-wizytówka
Astro 5 (SSG) + Tailwind v4 (przez @tailwindcss/vite, zero tailwind.config) · React 19 islands z motion
Aplikacja na tablet/TV
Capacitor 7 APK ładowany z server.url + allowNavigation · MainActivity + ScreenSchedule plugin (AlarmManager.setAlarmClock + SCREEN_BRIGHT_WAKE_LOCK)
Pogoda
Open-Meteo bez klucza, in-memory cache 30 min · 5 miast: Gdańsk, Olsztynek, Warszawa, Wrocław, Kraków
Infra
Nginx (HTTP/2, certbot, X-Accel-Redirect dla APK) · systemd × 4 unity (api, imap-worker, backup.timer, backup.service)
Backup
SQLite db.backup() online + tar.gz storage · codziennie 03:00 · retencja 4 dni (BACKUP_RETENTION_DAYS w .env, storage puchnie ~3 GB/dzień) · /home/ubuntu/backups/foto-emecity/

Flow: od maila do ekranu

📧
Mail rodziny
SMTP
📨
IMAP IDLE × 2
imapflow
🖼️
Pipeline
ImageMagick
💾
SQLite + storage
better-sqlite3
🔌
REST API
Express
📱
Capacitor APK
Tablet · TV

Słownik

Co dokładnie kryje się za pojęciami, których pozwoliliśmy sobie nie używać wyżej.

Allowlista
Lista zaufanych nadawców mailowych. Nieznany adres → poczekalnia (status=pending), zdjęcia widoczne tylko po manualnej akceptacji w panelu.
IMAP IDLE
Push-mode po stronie skrzynki — serwer mailowy sam mówi „nowy mail”, zamiast cyklicznego pollingu. Worker trzyma 3 połączenia jednocześnie (foto@madrzak.pl, fotomadrzak@gmail.com, melafoto@madrzak.pl), kto pierwszy ten lepszy. Każde konto może mieć opcjonalny IMAP_N_DEFAULT_FRAME_SLUG bindujący wszystkie maile do konkretnej ramki (np. melafoto → mela), z kolejnością rozpoznawania: tag #slug w temacie > bind konta > default ramki nadawcy.
Day-window
Para godzin (wake_hour, sleep_hour) per ramka. W oknie ramka chodzi non-stop z filmami. Poza oknem — 5 min po dotyku, potem MainActivity zdejmuje FLAG_KEEP_SCREEN_ON i AlarmManager usypia ekran przez DPM.lockNow().
Capacitor
Framework od Ionica do pakowania webowej aplikacji (nasz React 19 + Vite) w natywną appkę Android/iOS. WebView jako runtime + JS bridge do natywnych API przez pluginy w Kotlinie/Swift. Konkretnie u nas: na ramce frontend wywołuje window.Capacitor.Plugins.ScreenSchedule.setSchedule({sleepHour: 23, wakeHour: 7}), a po stronie Kotlina MainActivity programuje AlarmManager.setAlarmClock(...) — TV gaśnie o 23:00, budzi się o 7:00. Bez Capacitora to samo trzeba by pisać dwa razy (Kotlin + Swift) albo zostać przy zwykłym PWA bez prawdziwego budzika sprzętowego.
X-Accel-Redirect
Nginx-owy mechanizm: Express weryfikuje token PIN i odpowiada nagłówkiem X-Accel-Redirect na internal location, nginx serwuje plik APK z lokalnego dysku jak zwykły static. Range, streaming, zero bufora w Node.
Device token
Per-ramka Bearer token (32 hex). Wkleja się w URL fragment #t=... przy pierwszym otwarciu, potem żyje w localStorage. requireDeviceToken middleware wywołuje trackFrameAccess i UPSERT-uje (frame_id, ip) do frame_seen_ips — pierwsze nowe IP wyzwala mail przez nodemailer + Gmail SMTP.
PIN flow
Sześciocyfrowy kod (TTL 10 min) lub 32-hex link (TTL 24 h) do pobrania APK z /dl/:token. Token bindowany do IP po pierwszym hicie — kolejne requesty z tego samego IP w oknie OK, inne IP → 410. Rozwiązuje wzorzec Sony BRAVIA Downloader, który robi 2 requesty na ten sam URL (WebView preflight + DownloadManager Range).
Dedup po Message-ID + SHA-256
Pierwsza warstwa: Message-ID maila (idempotencja przy retry IMAP). Druga: SHA-256 oryginału pliku — ten sam plik wysłany dwukrotnie z różnych klientów dostaje jeden wpis.
Oryginały vs miniaturki
storage/originals/ — jedyna kopia oryginału (gitignored, do regeneracji). storage/display/ — wersja max 2048 px sRGB JPEG do slideshow. storage/thumbs/ — 320 px do listingu w panelu. Tylko display i thumbs są serwowane przez nginx; oryginały nigdy publicznie.
Weighted shuffle
Display.jsx ekspanduje pulę zdjęć przez wagi (każde id powielane N razy), potem Fisher-Yates, potem de-clustering sąsiadujących duplikatów. Trzy wagi się składają: base = ceil(senders.priority/2) (priority 1-2→1, 3-4→2, 10→5; obniżone od 2026-05-01, bo dla 1026 zdjęć przy bazie=priority pula rosła do ~2400 wpisów i cykl 13 h nie mieścił się w dziennym oknie 7-22:30), boost ulubionych (★ → ×2), freshness decay 7-dniowy z photo.takenAt (boosted = base + freshness×(10-base), max=10 dla świeżego, liniowo do base po 7 dniach). Cap 30 entries per zdjęcie. Reshuffle reaguje na: zmianę zestawu zdjęć, toggle z panelu, zmianę priority/★ (przez photosKey = id:priority:fav), upływ czasu (freshnessTick co godzinę), zawijanie cyklu (shuffleEpoch++). Efekt: świeży mail od mamy z dziś dominuje rotację (waga 10), za tydzień wraca do bazowej 2, archiwum 2014 zawsze rzadko (1).
lastIndexRef + reset po reshuffle
Pułapka weighted shuffle: lista photos ma duplikaty po wagach, więc list.findIndex(id) zawsze zwraca PIERWSZE wystąpienie. Stary setIndex iterował przez findIndex(currentId)+1 — pivot zawsze wracał do najwcześniejszej pozycji, co dawało mikro-pętlę 5 zdjęć z początku listy zamiast pełnego cyklu. Fix: useRef(0) zapamiętujący faktyczną expanded position; setIndex używa go jako pivot dopóki photos[lastIdx].id wciąż się zgadza z prevId, fallback do findIndex tylko gdy ref jest stale. Drugi fix (od 2026-05-01 wieczór): po bumpie shuffleEpoch useEffect wymusza currentId = photos[0].id i lastIndexRef = 0, bo bez tego fallback findIndex(prevId) w nowej kolejce skakał na pierwsze wystąpienie ostatnio widzianego ID (~1/N długości listy), więc drugi cykl startował w środku i pomijał pierwszą partię expanded queue — dla babci 30-50% zdjęć drugiego cyklu nie pojawiało się na ekranie. Sync też przy 'foto:new-photo' żeby kolejne next() startowało z aktualnej pozycji.
Status bar — dwa liczniki
Po tapnięciu pasek pokazuje dwa różne liczniki, oba z tym samym mianownikiem (= liczba unikalnych zdjęć ramki). Górny „135 / 1027” to pozycja PIERWSZEGO wystąpienia aktualnego ID w shuffled queue — skacze niemonotonicznie, bo expanded queue ma duplikaty po wadze i lecąc po slajdzie #340 (drugi pokaz tego samego zdjęcia) cofamy się do np. 135. Dolny „143 / 1027 (14%)” to liczba unikalnych zdjęć obejrzanych od początku bieżącego cyklu — rośnie monotonicznie, powtórki nie liczą. Dolny może być WIĘKSZY od górnego, gdy aktualny slajd to duplikat zdjęcia widzianego wcześniej (jego pierwsze wystąpienie ma niski numer, ale w międzyczasie minęły inne unikalne ID). Oba resetują się przy reshuffle (shuffleEpoch++). viewedInCycleRef = useRef(new Set()) trzyma ID, viewedCount jako useState do triggera rerenderu paska.
Bulk import + parser dat
scripts/bulk-import.js — import zdjęć z katalogu poza emailem (SCP archiwum, panel admin do dyspozycji jako alternatywa). Dedup po SHA-256: zdjęcie istniejące w innej ramce dolinkowane przez photo_frames.added_via='admin_bulk' bez duplikacji pliku. Fallback daty: EXIF DateTimeOriginal (ImageMagick %[EXIF:...]) → parser nazwy pliku (lib-filename-date.js obsługuje 2015.04.06-11.43_*, IMG_YYYYMMDD_HHMMSS, IMG-YYYYMMDD-WAxxxx, Screenshot_YYYYMMDD-HHMMSS). Dla zdjęć bez EXIF i bez parsowalnej nazwy: sentinel taken_at='2014-01-01T00:00:00' rozpoznany w StatusBar.jsx (jan 1, 00:00:00) renderuje 'Rok 2014' zamiast 'Styczeń 2014'.
MediaSession trap
Filmy (mediaType==='video') w slideshow trzymają MediaSession active, co blokuje wygaszanie ekranu nawet po naszym clearFlag(KEEP_SCREEN_ON). Display.jsx odfiltrowuje filmy poza day-window, żeby nocne wygaszenie faktycznie zadziałało.
Rotacja rezydenta
Gdy frames.resident_rotate=1, Resident.jsx ignoruje resident_kind i co 10 min (na granicy :00, :10, :20, :30, :40, :50 UTC) liczy effectiveKind = ['cat','dog','owl','bunny','panda','puppy','kitty','jumbo','crocodile_on_scooter','yacht','bunnies_in_boat'][Math.floor(Date.now() / 600_000) % 11]. setInterval(60_000) trzyma React aktualny, useEffect na zmianie kind reset'uje state i fetchuje nowy plik Lottie — bez restartu aplikacji. Wszystkie ramki z rotate=1 zsynchronizowane po zegarze UTC.
photo_frames M:N
Zdjęcia żyją w tabeli M:N (photo_id, frame_id, status, added_via, added_at) — to samo zdjęcie może być w kilku ramkach z niezależnym statusem (approved u Babci, hidden u Cioci). Worker IMAP robi dual-write w transakcji, a gdy hash już istnieje w innej ramce — auto-link 'imap_dedup' zamiast wyrzucenia jako duplikat. added_via to audit trail (imap / imap_dedup / admin_add / admin_bulk / migration). photos.frame_id zostaje jako legacy mirror do migracji 019.
forceReloadAt
Sygnał zdalnego reloadu frontendu na ramce. Admin klika w panelu → backend pisze do frames.force_reload_at = now(). Heartbeat (5 min) zwraca tę wartość; App.jsx trzyma ref seenForceReloadAt — pierwsza odpowiedź ustanawia baseline, każda kolejna zmiana → window.location.reload(). W trybie Capacitor APK reload pobiera świeże pliki z server.url bez App.exitApp() i bez podchodzenia do TV. Diagnostyka: vite.config.js wstrzykuje __APP_BUILD_HASH__ = sha1(BUILD_AT).slice(0,7), QuickMenu pokazuje (BUILD: 2b94c2c) — wzrokowo widać czy reload zaszedł.
configUpdatedAt
Drugi sygnał obok forceReloadAt — bumpowany przez admin/frames PATCH i device PATCH /:slug/config (force-reload nie bumpuje, czysta separacja). Heartbeat zwraca; App.jsx ma drugi ref seenConfigUpdatedAt → silent fetchFrameConfig + setConfig (bez reloadu). Po setConfig useEffect w Display.jsx wywołuje Capacitor.Plugins.ScreenSchedule.setSchedule(...) i AlarmManager przeprogramowuje alarmy. Czyli zmiana harmonogramu w panelu propaguje się sama w max 5 min, bez fizycznej interakcji z ramką.
banner_*
Trzy kolumny w frames (banner_text, banner_kind, banner_expires_at) — jeden aktywny komunikat per ramka, kind ∈ {info, news, alert} mapowane na kolory (zielony / niebieski / czerwony pulsujący). Backend zwraca banner w GET /api/frame/:slug/photos (piggyback na 30 s polling — dla rodzinnej ramki wystarczy). Frontend Banner.jsx renderuje overlay top z setInterval(1s) do auto-hide po expiresAt — TTL liczony klient-side, bez backendowego cron-cleanup. Endpointy: POST /:id/banner, DELETE /:id/banner, POST /banner-broadcast (UPDATE wszystkich).
ticker_*
Pięć kolumn w frames (ticker_text, ticker_mode, ticker_kind, ticker_speed, ticker_expires_at) — niezależny od banner news ticker na dole ekranu. Mode ∈ {scroll, static}, kind ∈ {neutral, info, news, alert} (default neutral — czarne tło, klasyczny news ticker look), speed ∈ {slow, medium, fast} presety mapowane na SPEED_PX_PER_S = {60, 120, 200} px/s. Tekst do 300 zn., TTL 5–86400 s. CSS animation tv-ticker-scroll (translateX 100vw → -100%), animationDuration liczony inline z (text.length × 16px + 1200px) / SPEED. Display.jsx gating przez interactiveActive — poza day-window ticker nie istnieje. Z-index 70 (StatusBar nad ticker'em, decyzja: user-triggered i krótki). Endpointy bliźniacze do banner: POST /:id/ticker, DELETE /:id/ticker, POST /ticker-broadcast. Migracja 021, piggyback na photos-poll.
Pauza slideshow
In-memory toggle (Display.jsx state, NIE persisted) zatrzymujący advance() — current photo wisi do czasu kolejnego next()/prev()/wyjścia z day-window. Trigger: klawisz P na klawiaturze, media-key || (KeyMediaPlayPause), przycisk w QuickMenu, gesture na touch — Spacja zostaje na OK (bo OK na pilocie TV = Enter = Space na keyboard, kolizja z otwarciem menu). Reset: prev/next, nowy photo w refresh, wyjście z interactiveActive (ramka usypia → wakeup zaczyna od czystego stanu). Brak persistence intencjonalna: pauza to mikro-akcja użytkownika, nie ustawienie.
Pilot Android TV (D-pad)
lib/remote-keys.js: isOkKey() łapie Enter/Select/Space/keyCode 13/32/23, isBackKey() Escape/BrowserBack/keyCode 4/8/27 — różne TV WebViews (Sony BRAVIA, Lenovo, Chromecast, Sharp AQUOS) emitują różne kombinacje pod KEYCODE_DPAD_CENTER / BACK. Mapping w slideshow: ←/→ prev/next, OK/↑ otwiera Szybkie menu, ↓ status bar, Back zamyka. QuickMenu: autofocus + ↑/↓ cykl pozycji. ThumbnailGrid: strzałki po grid (kolumny z getComputedStyle.gridTemplateColumns.split(/\s+/).length), OK otwiera chooser modal (Anuluj / Ukryj-Pokaż / Pokaż na ramce primary) — bo pilot TV emituje OK jako natychmiastowy click (keyup zaraz po keydown), long-press niewykrywalny. Touch (tablet) ma własny pointer-event pipeline z 600 ms timerem, niezmieniony. Debug: localStorage.foto:debug-keys='1' lub ?debug-keys=1. Pułapka focus(): zawsze { preventScroll: true } + ręczne scrollIntoView({ block: 'nearest' }) wewnątrz scrollowalnego rodzica — bez tego BRAVIA/AQUOS wykonują scrollIntoView na D-pad fokusie i przesuwają cały slideshow.
Slideshow watchdog
Dwa antyzawisowe timery w Display.jsx. (1) Slideshow.jsx — w gałęzi isVideo=true setTimeout(advance, max(60s, durationSec × 3)) capped 5 min — bez tego ramka zawisała na wideo, które po cichu nie odpaliło onEnded (autoplay-block, codec stuck, MediaSession). (2) Display.jsx — setInterval(15s) sprawdza Date.now() - lastAdvanceAt > max(90s, slideDur × 5) capped 5 min; jeśli currentId nie zmienił się tak długo, wymuszamy next(). Plus image preload: onerror + 30 s timeout, żeby zawiśnięty fetch (oddzielne 503 z nginx, połączenie reset) też wyzwalał advance. Wszystkie ścieżki logują console.warn('[slideshow*-watchdog] forcing advance', ...) do diagnostyki post-mortem.

Pragmatyzmy

  • 🪶
    ImageMagick CLI zamiast sharp.

    System ma już libheif — HEIC z iPhone'a działa za darmo, bez bindings.

  • 🪶
    better-sqlite3 synchronicznie.

    Bez async/await dla DB. Transakcje jako db.transaction(() => {...})(). Online db.backup() co noc, bez przerywania serwisu.

  • 🪶
    Capacitor zamiast TWA / czystego natywnego.

    Aplikacja ładuje frontend z server.url + allowNavigation — frontend update bez rebuilda APK. Service Worker wyłączony w native (anty-pattern obok PWA WebAPK).

  • 🪶
    setAlarmClock zamiast setExactAndAllowWhileIdle.

    Tylko AlarmClock + SCREEN_BRIGHT_WAKE_LOCK | ACQUIRE_CAUSES_WAKEUP wybudza fizycznie z Doze. Bez tego budzik dzwoni, ale ekran zostaje ciemny.

  • 🪶
    Astro 5 + Tailwind v4 dla landingu.

    SSG, ~50 kB HTML + CSS, React islands tylko tam gdzie potrzeba interakcji (animowany hero, demo PIN). Tailwind v4 bez tailwind.config.js — wszystko w @theme w globals.css.

  • 🪶
    Symulator slideshow w Node.

    scripts/simulate-slideshow.js odtwarza algorytm Display.jsx 1:1 (te same wagi, ten sam Fisher-Yates z opcjonalnym Mulberry32 seed dla regresji, ten sam lastIndexRef). Generuje pełen cykl, statystyki rozkładu per-priority, detector mikro-pętli (jeśli ostatnie 100 slajdów ma <30 unikalnych ID). Zamiast podpinać tablet do debuggera — odpalasz node simulate-slideshow.js --frame=babcia --seed=42 i widzisz dokładnie co zobaczy babcia.

Pełna specyfikacja MVP (24 sekcje, ~1200 linii) i dziennik decyzji — w prywatnym repo. Kontakt przez fotomadrzak@gmail.com.

🎁

Chcesz taką
u swojej Babci?

Cały projekt jest opisany — stack, decyzje, bolące pułapki Lenovo i Sony BRAVIA. Możesz rozłożyć na części, zbudować swoją wersję, dać Babci pierwszą paczkę zdjęć.

Aby Twoje zdjęcia trafiały na ramkę u Babci Madrzak — wyślij mail na fotomadrzak@gmail.com.