Skip to content

Commit d1b1ecc

Browse files
committed
Release 0.7.0: browser-side transition card, visual editor, frame_id
Merges release-0.7-transitions: custom Lovelace card with CSS-based transitions, ha-form schema-driven visual editor, per-frame entity_picture cache-busting via frame_id, and supporting camera/init refactors.
1 parent d68c861 commit d1b1ecc

7 files changed

Lines changed: 1293 additions & 62 deletions

File tree

README.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ All behavior is exposed as Home Assistant entities. Adjust everything live witho
4848
- Manual "Next slide" button still works while paused
4949
- Survives Home Assistant restarts
5050

51+
### ✨ Transitions
52+
- Smooth slide transitions rendered in the browser, so they stay buttery even on lower-end hardware
53+
- Effects: `random`, `none`, `fade`, `slide-left`, `slide-right`, `slide-up`, `slide-down`, `wipe-left`, `wipe-right`, `zoom`
54+
- `random` picks a different effect per slide (and avoids repeating the previous one)
55+
- Configurable duration and CSS easing
56+
- Aspect ratio + fill mode inheritance from the camera entity (cover / contain / blur backdrop)
57+
5158
### 🎨 Smart Rendering Engine
5259

5360
#### Orientation Mismatch Handling
@@ -82,7 +89,7 @@ The following entities allow you to adjust slideshow behavior without restarting
8289
| Number | Slide interval | 60 | Any positive integer (seconds) | Time between slides |
8390
| Number | Album refresh | 24 | Any positive integer (hours) | How often album contents refresh |
8491
| Number | Pair divider size | 8 | 0-64 (px) | Width of divider between paired images |
85-
| Number | Image cache size | 150 | 501000 (MB) | Memory budget for downloaded image data |
92+
| Number | Image cache size | 75 | 50-1000 (MB) | Memory budget for downloaded image data (per album) |
8693
| Select | Fill mode | blur | blur, cover, contain | How images fill the canvas |
8794
| Select | Orientation mismatch | pair | pair, single, avoid | Handling of portrait and landscape mismatch |
8895
| Select | Order mode | random | random, album_order, newest_taken, oldest_taken, newest_added, oldest_added | Slide ordering behavior |
@@ -201,6 +208,48 @@ The slideshow camera exposes per-frame metadata as attributes (use with `state_a
201208
| `byte_size` | int \| null | Original file size in bytes (Google Photos only) |
202209
| `paused` | bool | Whether the slideshow is paused |
203210
| `date_filter` | string | Active date filter mode |
211+
| `frame_id` | int | Monotonic counter incremented on every committed slide. Used by the [card](#-album-slideshow-card) to detect new frames |
212+
213+
---
214+
215+
## 🎞 Album Slideshow Card
216+
217+
The integration ships with a custom Lovelace card that does the slide-to-slide transition entirely in the browser. The server only renders one still per slide change; the card cross-fades in CSS, which the browser composites on the GPU. Result: a smooth dissolve on a Pi 4, even with several albums on screen.
218+
219+
The card is registered automatically when the integration loads; you do **not** need to add it as a HACS frontend repository or configure a Lovelace resource manually. After installing or upgrading, hard-refresh the dashboard once (Ctrl+Shift+R) so the browser picks up the script.
220+
221+
A visual editor is available - pick **Album Slideshow** from the card picker in Lovelace and the form will appear automatically.
222+
223+
### Minimal example
224+
225+
```yaml
226+
type: custom:album-slideshow-card
227+
entity: camera.album_slideshow_living_room
228+
```
229+
230+
### Full options
231+
232+
```yaml
233+
type: custom:album-slideshow-card
234+
entity: camera.album_slideshow_living_room
235+
transition: random # random | none | fade | slide-left
236+
# | slide-right | slide-up | slide-down
237+
# | wipe-left | wipe-right | zoom
238+
duration: 800 # ms; CSS transition length
239+
easing: ease-in-out # any CSS timing function (ease, linear, cubic-bezier(...))
240+
aspect_ratio: 16/9 # CSS aspect-ratio value (16/9, 4/3, 1/1, auto)
241+
fit: auto # auto | cover | contain
242+
# auto inherits the camera's fill_mode (cover / contain / blur)
243+
background: '#000' # color shown behind contained images
244+
tap_action: none # none | more-info
245+
```
246+
247+
### Notes
248+
249+
- `transition: random` picks a different effect per slide and avoids repeating the previous one.
250+
- `fit: auto` reads the camera's `fill_mode` attribute. `blur` renders the slide as `contain` plus a blurred backdrop layer behind it.
251+
- Every slide commit increments the camera's `frame_id` attribute. The card cache-busts the camera proxy URL with that value, so the browser refetches a fresh JPEG on every change instead of serving a stale cached image.
252+
- If the entity is unavailable, the card shows a "Camera not ready" placeholder.
204253

205254
---
206255

custom_components/album_slideshow/__init__.py

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,163 @@
11
from __future__ import annotations
22

3+
import asyncio
4+
import json
5+
import logging
6+
import os
7+
38
from homeassistant.config_entries import ConfigEntry
49
from homeassistant.core import HomeAssistant
510
from homeassistant.helpers import entity_registry as er
611

712
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
813
from .store import SlideshowStore
914

15+
_LOGGER = logging.getLogger(__name__)
16+
1017
PLATFORMS: list[str] = ["camera", "sensor", "button", "number", "select", "text", "switch"]
1118

19+
CARD_STATIC_PATH = "/album_slideshow_static"
20+
CARD_FILE = "album-slideshow-card.js"
21+
22+
23+
async def _async_register_card(hass: HomeAssistant) -> None:
24+
"""Serve the Lovelace card JS and register it as a frontend module.
25+
26+
Idempotent: only the first config entry to load triggers registration
27+
for the HA session. The card lets dashboards cross-fade between slides
28+
in the browser (GPU compositor) instead of forcing the camera entity
29+
to render a JPEG burst on the event loop.
30+
"""
31+
if hass.data.get(DOMAIN, {}).get("card_registered"):
32+
return
33+
34+
integration_dir = os.path.dirname(__file__)
35+
www_dir = os.path.join(integration_dir, "www")
36+
card_path = os.path.join(www_dir, CARD_FILE)
37+
38+
if not os.path.isfile(card_path):
39+
# Some HACS upgrade paths (and broken zip extractors) drop the
40+
# ``www/`` subdirectory. Try to recover by checking whether the
41+
# integration root has the file under a literal-backslash name
42+
# (a symptom of zips written with Windows path separators) or
43+
# directly at the root, and salvage it into ``www/`` so the
44+
# rest of the registration can proceed.
45+
recovered = await hass.async_add_executor_job(
46+
_recover_card_from_root, integration_dir, www_dir, card_path
47+
)
48+
if not recovered:
49+
_LOGGER.warning(
50+
"Album Slideshow card missing on disk (%s). Re-install"
51+
" the integration via HACS (3-dot menu -> Redownload)"
52+
" or copy %s/%s into the album_slideshow folder."
53+
" The custom:album-slideshow-card type will not be"
54+
" available until this is fixed.",
55+
card_path,
56+
"www",
57+
CARD_FILE,
58+
)
59+
return
60+
61+
try:
62+
from homeassistant.components.http import StaticPathConfig
63+
64+
await hass.http.async_register_static_paths(
65+
[StaticPathConfig(CARD_STATIC_PATH, www_dir, False)]
66+
)
67+
except Exception: # noqa: BLE001 - many possible failure modes here
68+
_LOGGER.exception(
69+
"Failed to register static path for Album Slideshow card"
70+
)
71+
return
72+
73+
# Cache-bust the card URL with the integration version so dashboards
74+
# always pick up the script that matches the running integration
75+
# rather than a stale copy from a previous release.
76+
version = await hass.async_add_executor_job(
77+
_read_manifest_version, integration_dir
78+
)
79+
card_url = f"{CARD_STATIC_PATH}/{CARD_FILE}"
80+
if version:
81+
card_url = f"{card_url}?v={version}"
82+
83+
try:
84+
from homeassistant.components.frontend import add_extra_js_url
85+
86+
add_extra_js_url(hass, card_url)
87+
except Exception: # noqa: BLE001
88+
_LOGGER.exception(
89+
"Failed to add Album Slideshow card to Lovelace; you can"
90+
" still register it manually as a resource at %s",
91+
card_url,
92+
)
93+
return
94+
95+
hass.data.setdefault(DOMAIN, {})["card_registered"] = True
96+
_LOGGER.info("Album Slideshow card registered at %s", card_url)
97+
98+
99+
def _read_manifest_version(integration_dir: str) -> str | None:
100+
try:
101+
with open(
102+
os.path.join(integration_dir, "manifest.json"),
103+
"r",
104+
encoding="utf-8",
105+
) as fh:
106+
return json.load(fh).get("version")
107+
except Exception: # noqa: BLE001
108+
return None
109+
110+
111+
def _recover_card_from_root(
112+
integration_dir: str, www_dir: str, card_path: str
113+
) -> bool:
114+
"""Salvage the card file from a broken extraction.
115+
116+
PowerShell's ``Compress-Archive`` writes zip entries with backslash
117+
separators, which Linux unzip implementations may treat as literal
118+
filenames. The resulting layout is::
119+
120+
custom_components/album_slideshow/www\\album-slideshow-card.js
121+
122+
instead of the expected ``www/album-slideshow-card.js``. Move it to
123+
the right place so subsequent installs don't need a re-download.
124+
"""
125+
candidates = [
126+
os.path.join(integration_dir, f"www\\{CARD_FILE}"),
127+
os.path.join(integration_dir, CARD_FILE),
128+
]
129+
for src in candidates:
130+
if os.path.isfile(src):
131+
try:
132+
os.makedirs(www_dir, exist_ok=True)
133+
os.replace(src, card_path)
134+
_LOGGER.info(
135+
"Recovered Album Slideshow card from %s", src
136+
)
137+
return True
138+
except OSError:
139+
_LOGGER.exception(
140+
"Found candidate card at %s but could not move it"
141+
" to %s",
142+
src,
143+
card_path,
144+
)
145+
return False
146+
return False
147+
12148

13149
async def _async_cleanup_legacy_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
14150
registry = er.async_get(hass)
151+
# Server-side transitions were tried in earlier 0.7-rc builds and
152+
# removed before the first public 0.7 pre-release because the
153+
# resource cost on low-end hardware was too high. Drop any leftover
154+
# transition entities so users don't see stale disabled rows under
155+
# the device.
15156
legacy_unique_ids = {
16157
f"{entry.entry_id}_max_items",
158+
f"{entry.entry_id}_transition",
159+
f"{entry.entry_id}_transition_duration_ms",
160+
f"{entry.entry_id}_transition_fps",
17161
}
18162

19163
for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
@@ -25,6 +169,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
25169
from .coordinator import AlbumCoordinator
26170

27171
hass.data.setdefault(DOMAIN, {})
172+
# Domain-wide concurrency limit on compose work. Multiple album cameras
173+
# share HA's small executor pool; without coordination they can all
174+
# decode + render in parallel and saturate the loop. One ticket means
175+
# at most one album does PIL work at a time, queueing the rest.
176+
if "compose_semaphore" not in hass.data[DOMAIN]:
177+
hass.data[DOMAIN]["compose_semaphore"] = asyncio.Semaphore(1)
178+
179+
# Register the Lovelace card once per HA session. The card runs the
180+
# GPU-composited transitions in the browser so the camera entity
181+
# itself never has to render a transition burst on the event loop.
182+
await _async_register_card(hass)
28183

29184
await _async_cleanup_legacy_entities(hass, entry)
30185

@@ -76,5 +231,13 @@ async def _refresh_album(call) -> None:
76231
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
77232
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
78233
if unload_ok:
79-
hass.data[DOMAIN].pop(entry.entry_id, None)
234+
domain_data = hass.data.get(DOMAIN, {})
235+
domain_data.pop(entry.entry_id, None)
236+
# Drop the shared semaphore once the last album is gone so it's
237+
# re-created if the integration is re-added later.
238+
entry_keys = [
239+
k for k in domain_data.keys() if k != "compose_semaphore"
240+
]
241+
if not entry_keys:
242+
domain_data.pop("compose_semaphore", None)
80243
return unload_ok

0 commit comments

Comments
 (0)