A screensaver for Google TV that hangs pictures rather than showing slides.
Point it at a JSON manifest of images and it composes them into frames — one picture alone, two or three justified across the width, a hero beside a stack — choosing an arrangement per frame from the images' own aspect ratios. Nothing is distorted, nothing is cropped that shouldn't be, and nothing is enlarged past what its pixels support.
Runs as an Android DreamService hosting a WebView, so the whole renderer is
one HTML file you can open in a browser.
Four laws. Everything else in the layout engine is derived from them, and any change has to preserve all four.
1 — Geometry. One gutter G = round(0.010 × frameWidth). It is the outer
margin, the seam between regions, the gap between images, and the clearance text
must keep from art. Text anchors sit 2G in from the edge. Leftover space is
mat; gutters are never stretched to fill it.
2 — Honesty. Aspect ratios are exact, always. A landscape may be cropped to fill a region only if it is at least 1.20 wide, only up to 18%, and only if it has the pixels to survive it. Portraits and squares are never cropped. No image is displayed larger than 2× its native size. A frame that cannot satisfy all of this is skipped, never rendered degraded.
3 — Timeline. Fade through black, never crossfade — two artworks at partial
opacity muddy each other. Everything departs together; then a beat of black;
then slot k arrives at k × stagger. Images take slots 0…N−1 in reading
order, and a frame's text takes slot N−1 so it lands with the last image. On a
single-image frame that degenerates to slot 0, which is exact lockstep with no
special case.
4 — Furniture. Caption lower-left, slide counter lower-right, clock and
weather upper-right. All anchored, never drifting. Each appears only when it
clears every image by at least G — hidden beats overlapping. Legibility comes
from a shadow carried by each glyph, never from a panel or a gradient.
./deploy.sh <tv-ip>Builds, installs, registers the dream, verifies the read-back and triggers it immediately so you don't wait for idle.
Order matters, and the script enforces it. Auto-start is granted before anything else. Several manufacturers refuse to launch a dream's process once it has been killed, and that block looks identical to success until the app is force-stopped. The real test — which the script prints — is:
adb shell am force-stop com.jameslepage.salon
adb shell am start -n com.android.systemui/.SomnambulatorIf it still appears after that, auto-start genuinely works on your device.
Enable developer options (Settings ▸ System ▸ About, then press Build seven times), then switch on ADB debugging — Sony labels it that rather than "USB debugging". Accept the authorization dialog that appears on the TV, ticking always allow. On Android 11+ some devices require pairing first, under Wireless debugging ▸ Pair device; the script detects this and prints the steps.
The app reads an optional URL at every dream start:
echo "http://<your-machine>:8777/layout-prototype.html?mode=ambient&debug=1" > url.txt
adb push url.txt /sdcard/Android/data/com.jameslepage.salon/files/url.txtNow the TV renders from your dev server. Edit the HTML, restart the dream, look. No Gradle, no reinstall. Delete the file to return to the bundled copy.
It must live in the app's own external files directory — scoped storage on
Android 11+ blocks reading arbitrary /sdcard paths whatever permissions are
declared, and a plain /sdcard file fails silently in a way that looks exactly
like the override being ignored.
?debug=1 shows a HUD with the chosen arrangement, fill percentage, and which
furniture is suppressed on the current frame.
| parameter | effect |
|---|---|
mode=ambient | preview |
living-room pacing, or fast cycling for judging layouts |
hold=<seconds> |
pin the hold exactly; otherwise it varies 30–60s per frame |
frames=<n> |
frames per artist before moving on |
artistmins=<n> |
cap minutes spent on one artist |
chrome=off |
hide clock and weather |
manifest=<url> |
load a different manifest |
Remote first, so publishing new art is a git push and nothing else. Falls back
to the last good copy in localStorage, then to the copy bundled in the APK —
so it survives both a dropped network and a bad publish.
A flat array of posts:
[
{
"artist": "Robert Canali",
"note": "Only the great generalizations survive",
"url": "https://example.com/the-post",
"images": [
{ "url": "https://example.com/one.jpg", "w": 1016, "h": 700 }
]
}
]Or an envelope, when you want several sources with different weights:
{
"version": 1,
"sources": [
{ "id": "example", "name": "Example", "weight": 1.0, "posts": [ … ] }
]
}artist leads the caption; note follows it, or stands alone when there's no
credit. w and h are required on every image — the engine plans a frame
from aspect ratios before fetching anything, so an image without dimensions
can't be composed. If your source doesn't publish them, a 4KB HTTP range request
reads them straight from the JPEG or PNG header without downloading the file.
data/sample-manifest.json is a small working example — 18 posts — so a fresh
clone runs immediately:
cp data/sample-manifest.json app/src/main/assets/dataset.jsonThis project deliberately ships no crawler. Building a manifest is specific to wherever your images live, and a general-purpose scraper pointed at someone else's archive isn't something to hand out by default. Write the JSON however suits you — by hand, from a folder, from a CMS API, from an export.
If you do index a site you don't own: read its robots.txt and honor
Crawl-delay, identify yourself, and remember that the screensaver streams from
that host on every frame forever. Getting rate-limited doesn't cost you a crawl,
it costs you the images.
Salon never copies artwork. The manifest holds URLs and dimensions; images are fetched from wherever they already live, at display time.
Built against a Sony BRAVIA 4K (Android 12, API 31). The Android 12 Sony update has a known bug that renders video surfaces at quarter size; Salon draws into a WebView and is unaffected.
True black (#000) is deliberate: on OLED those pixels are off, giving infinite
contrast and no wear in the mat. On an LCD you may prefer lifting it slightly to
hide backlight non-uniformity — one value, at the top of the stylesheet.
MIT.


