Skip to content

Repository files navigation

Salon

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.

Two images justified across the frame

Runs as an Android DreamService hosting a WebView, so the whole renderer is one HTML file you can open in a browser.


The contract

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.

A landscape that earned the whole frame


Running it

./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/.Somnambulator

If it still appears after that, auto-start genuinely works on your device.

Getting a TV to accept a connection

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.


Iterating without rebuilding

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.txt

Now 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.

Tuning

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

The 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.json

Indexing your own

This 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.

Graphite drawing, full bleed


Notes

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.

About

A screensaver for Google TV that hangs pictures — a layout engine that composes images into frames from their aspect ratios.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages