This commit is contained in:
Home Assistant Version Control
2026-09-25 04:10:30 +00:00
parent 56c80f896e
commit ad634c1b84
60 changed files with 2895 additions and 794 deletions
+93 -1
View File
@@ -5,12 +5,22 @@ import json
import logging
import os
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_PREVIOUS_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .const import (
SERVICE_HIDE_PHOTO,
SERVICE_UNDO_HIDE,
SERVICE_RESTORE_PHOTOS,
SERVICE_RESTORE_ALL_PHOTOS,
SERVICE_LIST_HIDDEN_PHOTOS,
)
from .store import SlideshowStore
_LOGGER = logging.getLogger(__name__)
@@ -353,6 +363,83 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
return True
def _register_photo_services(hass: HomeAssistant) -> None:
def camera_for(call):
camera = hass.data.get(DOMAIN, {}).get(call.data[ATTR_ENTRY_ID], {}).get("camera")
if camera is None:
raise ServiceValidationError("This album slideshow is not loaded")
return camera
async def hide_photo(call):
camera = camera_for(call)
try:
await camera.async_hide_photo(
photo_ids=call.data.get("photo_ids"),
position=call.data.get("position"),
frame_id=call.data.get("frame_id"),
)
except (ValueError, OSError) as err:
raise ServiceValidationError(str(err)) from err
async def undo_hide(call):
await camera_for(call).async_restore_photos(undo=True)
async def restore_photos(call):
await camera_for(call).async_restore_photos(call.data["photo_ids"])
async def restore_all_photos(call):
await camera_for(call).async_restore_photos()
async def list_hidden_photos(call):
camera = camera_for(call)
hidden = sorted(camera.store.hidden_photo_ids)
offset = call.data.get("offset", 0)
limit = call.data.get("limit", 50)
items = {
item.photo_id: item
for item in (camera.coordinator.data or {}).get("items", [])
if item.photo_id in camera.store.hidden_photo_ids
}
return {
"total": len(hidden),
"offset": offset,
"photos": [
{
"photo_id": photo_id,
"name": (items[photo_id].filename if photo_id in items else None) or f"Photo {photo_id[-8:]}",
"in_album": photo_id in items,
}
for photo_id in hidden[offset:offset + limit]
],
}
identifiers = vol.All([str], vol.Length(min=1))
registrations = (
(SERVICE_HIDE_PHOTO, hide_photo, {
vol.Optional("photo_ids"): identifiers,
vol.Optional("position"): vol.In(["first", "second", "both"]),
vol.Optional("frame_id"): vol.All(vol.Coerce(int), vol.Range(min=0)),
}),
(SERVICE_UNDO_HIDE, undo_hide, {}),
(SERVICE_RESTORE_PHOTOS, restore_photos, {vol.Required("photo_ids"): identifiers}),
(SERVICE_RESTORE_ALL_PHOTOS, restore_all_photos, {}),
(SERVICE_LIST_HIDDEN_PHOTOS, list_hidden_photos, {
vol.Optional("offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Optional("limit", default=50): vol.All(vol.Coerce(int), vol.Range(min=1, max=100)),
}),
)
for name, handler, fields in registrations:
if not hass.services.has_service(DOMAIN, name):
hass.services.async_register(
DOMAIN, name, handler,
schema=vol.Schema({vol.Required(ATTR_ENTRY_ID): str, **fields}),
supports_response=(
SupportsResponse.ONLY if name == SERVICE_LIST_HIDDEN_PHOTOS
else SupportsResponse.NONE
),
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
from .coordinator import AlbumCoordinator
@@ -372,6 +459,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await _async_cleanup_legacy_entities(hass, entry)
store = SlideshowStore()
try:
await store.async_load_hidden_photos(hass, entry.entry_id)
except Exception as err:
raise ConfigEntryNotReady("Could not load hidden photos; slideshow has not started") from err
coordinator = AlbumCoordinator(hass, entry, store)
await coordinator.async_config_entry_first_refresh()
@@ -423,6 +514,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not hass.services.has_service(DOMAIN, SERVICE_REFRESH_ALBUM):
hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album)
_register_photo_services(hass)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
store.notify()
@@ -6,6 +6,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .const import SERVICE_HIDE_PHOTO, SERVICE_UNDO_HIDE
from .coordinator import AlbumCoordinator
@@ -16,6 +17,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
PreviousSlideButton(hass, entry, coordinator),
NextSlideButton(hass, entry, coordinator),
RefreshAlbumButton(hass, entry, coordinator),
HidePhotoButton(hass, entry, coordinator),
UndoHideButton(hass, entry, coordinator),
]
)
@@ -77,3 +80,29 @@ class RefreshAlbumButton(_BaseButton):
{ATTR_ENTRY_ID: self.entry.entry_id},
blocking=False,
)
class HidePhotoButton(_BaseButton):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(hass, entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_hide_photo"
self._attr_name = "Hide current photo"
self._attr_icon = "mdi:eye-off"
async def async_press(self) -> None:
await self.hass.services.async_call(
DOMAIN, SERVICE_HIDE_PHOTO, {ATTR_ENTRY_ID: self.entry.entry_id}, blocking=True
)
class UndoHideButton(_BaseButton):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(hass, entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_undo_hide"
self._attr_name = "Undo hide"
self._attr_icon = "mdi:undo"
async def async_press(self) -> None:
await self.hass.services.async_call(
DOMAIN, SERVICE_UNDO_HIDE, {ATTR_ENTRY_ID: self.entry.entry_id}, blocking=True
)
+204 -20
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from collections import OrderedDict, deque
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import datetime, timezone
import logging
import random
@@ -303,6 +303,25 @@ class AlbumSlideshowCamera(Camera):
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
captured_at_pair = self._last_captured_at_pair
return {
"entry_id": self.entry.entry_id,
"displayed_photo_ids": (
self._current_frame.meta.get("photo_ids", [])
if self._current_frame is not None and not any(
photo_id in self.store.hidden_photo_ids
for photo_id in self._current_frame.meta.get("photo_ids", [])
) else []
),
"hidden_photo_count": len(self.store.hidden_photo_ids),
"hidden_revision": self.store.hidden_revision,
"undo_hide_available": bool(self.store.last_hidden_photo_ids),
"empty_reason": (
None if items else (
"all_hidden" if data.get("items") and all(
item.photo_id in self.store.hidden_photo_ids
for item in data["items"]
) else "no_matching_photos"
)
),
"album_title": data.get("title"),
"media_count": len(items),
"media_count_total": len(data.get("items", []) or []),
@@ -412,12 +431,14 @@ class AlbumSlideshowCamera(Camera):
self.store.date_filter,
self.store.missing_date_mode,
self.store.order_mode,
self.store.hidden_revision,
)
if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key):
return self._effective_cache[1]
filtered = playlist.filter_items(
raw,
[item for item in raw if item.photo_id and item.photo_id not in self.store.hidden_photo_ids]
if self.store.hidden_photo_ids else raw,
mode=self.store.date_filter,
missing_date=self.store.missing_date_mode,
)
@@ -476,6 +497,161 @@ class AlbumSlideshowCamera(Camera):
async def async_force_refresh(self) -> None:
await self.coordinator.async_request_refresh()
async def async_hide_photo(
self,
*,
photo_ids: list[str] | None = None,
position: str | None = None,
frame_id: int | None = None,
) -> None:
async with self._navigation_lock:
if frame_id is not None and frame_id != self._frame_id:
raise ValueError("The displayed slide changed; try again")
if photo_ids is not None and position is not None:
raise ValueError("Choose photo IDs or a position, not both")
if photo_ids is None:
current_ids = (
self._current_frame.meta.get("photo_ids", [])
if self._current_frame is not None else []
)
if not current_ids:
raise ValueError("There is no photo to hide")
if len(current_ids) > 1 and position is None:
raise ValueError("This slide is paired; choose first, second, or both")
if position == "second" and len(current_ids) < 2:
raise ValueError("This slide has no second photo")
if position not in (None, "first", "second", "both"):
raise ValueError("Invalid photo position")
photo_ids = (
current_ids if position == "both"
else [current_ids[1 if position == "second" else 0]]
)
known_ids = {
item.photo_id for item in (self.coordinator.data or {}).get("items", [])
if item.photo_id
}
for frame in [self._current_frame, *self._previous_frames, *self._next_frames]:
if frame is not None:
known_ids.update(photo_id for photo_id in frame.meta.get("photo_ids", []) if photo_id)
if not photo_ids or any(not photo_id or photo_id not in known_ids for photo_id in photo_ids):
raise ValueError("Photo identifier unavailable; refresh the album and try again")
await self._async_change_exclusions(photo_ids, hidden=True)
async def async_restore_photos(
self, photo_ids: list[str] | None = None, *, undo: bool = False
) -> None:
async with self._navigation_lock:
selected = (
self.store.last_hidden_photo_ids if undo
else self.store.hidden_photo_ids if photo_ids is None
else photo_ids
)
await self._async_change_exclusions(selected, hidden=False)
async def _async_change_exclusions(self, photo_ids, *, hidden: bool) -> None:
generation = self._timeline_generation
buffered_frames = (
() if self._timeline_dirty
else tuple(frame for frame in (self._current_frame, *self._next_frames) if frame is not None)
)
if await self.store.async_set_photos_hidden(photo_ids, hidden=hidden):
if self._timeline_generation != generation + 1:
buffered_frames = ()
await self._rebuild_after_exclusion_change(buffered_frames)
async def _rebuild_after_exclusion_change(
self, buffered_frames: tuple[_RenderedFrame, ...] = ()
) -> None:
self._effective_cache = None
self._invalidate_timeline()
self._random_order = []
self._random_pos = 0
self._recent_urls = []
items = self._effective_items()
positions = {item.photo_id: index for index, item in enumerate(items) if item.photo_id}
reusable = []
for frame in buffered_frames:
photo_ids = frame.meta.get("photo_ids", [])
if not photo_ids or any(photo_id not in positions for photo_id in photo_ids):
continue
cursor = replace(
frame.cursor,
index=positions[photo_ids[0]],
random_order=(),
random_pos=0,
recent_urls=(),
)
reusable.append(replace(frame, cursor=cursor))
if reusable:
self._timeline_dirty = False
self._next_frames.extend(reusable[1:])
self._trim_timeline()
self._apply_frame(reusable[0])
self._schedule_preload()
return
generation = self._timeline_generation
await self._clear_current_frame()
for frame in buffered_frames:
if generation != self._timeline_generation:
break
photo_ids = frame.meta.get("photo_ids", [])
orientation = frame.meta.get("pair_orientation")
if len(photo_ids) != 2 or orientation not in ("horizontal", "vertical"):
continue
remaining = [
(half, photo_id) for half, photo_id in enumerate(photo_ids)
if photo_id in positions
]
if len(remaining) != 1:
continue
half, photo_id = remaining[0]
try:
encoded = await self._async_image_job(
ip.render_pair_photo, frame.data, half, orientation == "vertical",
max(0, int(self.store.pair_divider_px)), self.store.fill_mode,
)
except Exception as err:
_LOGGER.debug("Album Slideshow: could not reuse paired photo: %s", err)
continue
if generation != self._timeline_generation:
break
cursor = replace(
frame.cursor, index=positions[photo_id],
random_order=(), random_pos=0, recent_urls=(),
)
meta = {
**frame.meta,
"photo_ids": [photo_id],
"captured_at_pair": None,
"pair_frames": None,
"pair_orientation": None,
}
self._timeline_dirty = False
self._apply_frame(_RenderedFrame(encoded, cursor, meta))
self._schedule_preload()
return
try:
await self._rebuild_current_frame()
except Exception as err:
_LOGGER.warning("Album Slideshow: photos updated but replacement render failed: %s", err)
async def _clear_current_frame(self) -> None:
self._current_frame = None
self._framebuffer = None
self.store.last_frame = None
self._last_is_portrait = None
self._last_captured_at_pair = None
self._last_pair_frames = None
self._last_pair_orientation = None
def blank_frame():
with Image.new("RGB", (16, 9), "black") as image:
return ip.encode_image(image)
self._framebuffer = await self.hass.async_add_executor_job(blank_frame)
self._frame_id += 1
self.async_write_ha_state()
async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
return self._framebuffer
@@ -577,14 +753,13 @@ class AlbumSlideshowCamera(Camera):
composed: Image.Image | None = None
try:
async with self._compose_semaphore:
composed, meta = await renderer._compose_for_index(items)
cursor = self._capture_cursor(renderer)
if composed is None:
raise RuntimeError("Image composition returned no frame")
encoded = await self.hass.async_add_executor_job(
ip.encode_image, composed
)
composed, meta = await renderer._compose_for_index(items)
meta = dict(meta or {})
meta.setdefault("photo_ids", [getattr(items[renderer._index], "photo_id", None)])
cursor = self._capture_cursor(renderer)
if composed is None:
raise RuntimeError("Image composition returned no frame")
encoded = await self._async_image_job(ip.encode_image, composed)
return _RenderedFrame(encoded, cursor, meta or {})
except asyncio.CancelledError:
raise
@@ -800,6 +975,7 @@ class AlbumSlideshowCamera(Camera):
self._timeline_dirty = False
items = self._effective_items()
if not items:
await self._clear_current_frame()
return False
cursor = (
@@ -908,6 +1084,10 @@ class AlbumSlideshowCamera(Camera):
domain_data["compose_semaphore"] = sem
return sem
async def _async_image_job(self, function, *args):
async with self._compose_semaphore:
return await self.hass.async_add_executor_job(function, *args)
def _do_advance(self, count: int, items: list) -> None:
"""Advance _index to the next slide and commit random-order position."""
if count <= 0:
@@ -978,7 +1158,7 @@ class AlbumSlideshowCamera(Camera):
if not cur_bytes:
raise RuntimeError(f"Failed to fetch image: {cur.url}")
img = await self.hass.async_add_executor_job(
img = await self._async_image_job(
ip.open_image, cur_bytes, (width, height)
)
try:
@@ -1000,7 +1180,7 @@ class AlbumSlideshowCamera(Camera):
pair_frames: list[dict] | None = None
try:
if other_img is not None:
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.pair_images, img, other_img, width, height, fill_mode,
is_portrait_canvas, divider, divider_fill, transparent_divider,
)
@@ -1022,12 +1202,16 @@ class AlbumSlideshowCamera(Camera):
]
pair_meta = [f["captured_at"] for f in pair_frames]
else:
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height,
)
finally:
ip.safe_close(other_img)
meta = {
"photo_ids": (
[getattr(cur, "photo_id", None), getattr(other_item, "photo_id", None)]
if pair_frames else [getattr(cur, "photo_id", None)]
),
"is_portrait": cur_is_portrait,
"captured_at_pair": pair_meta,
"pair_frames": pair_frames,
@@ -1041,7 +1225,7 @@ class AlbumSlideshowCamera(Camera):
}
return composed, meta
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
@@ -1086,14 +1270,14 @@ class AlbumSlideshowCamera(Camera):
if not b:
self._peek_advance(count, items)
continue
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
img = await self._async_image_job(ip.open_image, b, (width, height))
try:
if ip.is_portrait_item(cur, img) != is_portrait_canvas:
self._peek_advance(count, items)
continue
if self._index != start:
self._do_advance(count, items)
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
@@ -1116,10 +1300,10 @@ class AlbumSlideshowCamera(Camera):
b = await self._fetch_bytes(item.url)
if not b:
return None, None
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
img = await self._async_image_job(ip.open_image, b, (width, height))
try:
cur_is_portrait = ip.is_portrait_item(item, img)
composed = await self.hass.async_add_executor_job(
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height
)
return composed, {
@@ -1139,7 +1323,7 @@ class AlbumSlideshowCamera(Camera):
if composed is None:
return None
try:
return await self.hass.async_add_executor_job(ip.encode_image, composed)
return await self._async_image_job(ip.encode_image, composed)
finally:
ip.safe_close(composed)
@@ -1213,7 +1397,7 @@ class AlbumSlideshowCamera(Camera):
continue
try:
img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
img = await self._async_image_job(ip.open_image, b, (width, height))
except Exception:
continue
@@ -1,5 +1,11 @@
DOMAIN = "album_slideshow"
SERVICE_HIDE_PHOTO = "hide_photo"
SERVICE_UNDO_HIDE = "undo_hide"
SERVICE_RESTORE_PHOTOS = "restore_photos"
SERVICE_RESTORE_ALL_PHOTOS = "restore_all_photos"
SERVICE_LIST_HIDDEN_PHOTOS = "list_hidden_photos"
CONF_PROVIDER = "provider"
CONF_ALBUM_URL = "album_url"
CONF_ALBUM_NAME = "album_name"
@@ -3,8 +3,10 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import timedelta
from hashlib import sha256
import json
import logging
import os
from pathlib import Path
import re
from typing import Any
@@ -130,6 +132,17 @@ class MediaItem:
# background enrichment task skip already-processed files even after
# an HA restart (the flag round-trips through the items cache).
exif_scanned: bool = False
photo_id: str | None = None
def __post_init__(self) -> None:
if self.photo_id is None:
self.photo_id = _photo_identifier(self.source_id)
def _photo_identifier(source_id: str | None) -> str | None:
if not source_id:
return None
return "v1:" + sha256(str(source_id).encode("utf-8")).hexdigest()
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
@@ -1198,6 +1211,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
description=raw.get("description"),
source_id=raw.get("source_id"),
exif_scanned=bool(raw.get("exif_scanned", False)),
photo_id=raw.get("photo_id"),
))
except Exception:
continue
@@ -1230,6 +1244,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
"description": it.description,
"source_id": it.source_id,
"exif_scanned": it.exif_scanned,
"photo_id": it.photo_id,
}
for it in items
],
@@ -1290,6 +1305,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
height=None,
mime_type=None,
filename=p.name,
source_id=f"file://{os.path.abspath(p)}",
)
)
@@ -1349,6 +1365,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
height=None,
mime_type=resolved[1],
filename=title,
source_id=cid,
)
)
@@ -2037,6 +2054,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
# The DAV href, so enrichment can fetch the original bytes
# even when the display URL is a preview.
source_id=href,
photo_id=_photo_identifier(p.get("file_id") or href),
)
)
@@ -2552,6 +2570,11 @@ class AlbumCoordinator(DataUpdateCoordinator):
page_items = _find_largest_item_list(result) or _find_largest_item_list(data)
api_items: list[MediaItem] = []
dropped_videos = 0
scraped_ids = {
_photo_base_key(item.url): item.source_id
for item in scraped_items
if item.source_id
}
if page_items:
seen_urls: set[str] = set()
for raw in page_items:
@@ -2586,6 +2609,7 @@ class AlbumCoordinator(DataUpdateCoordinator):
captured_at=captured_at,
uploaded_at=None,
byte_size=byte_size,
source_id=raw.get("id") or scraped_ids.get(_photo_base_key(url)),
))
# Cross-source date enrichment: publicalbum.org often returns a fuller
@@ -394,6 +394,7 @@ def _parse_album_item(raw: Any) -> MediaItem | None:
captured_at=captured_at,
uploaded_at=uploaded_at,
byte_size=byte_size,
source_id=_media_key(raw),
)
@@ -165,6 +165,32 @@ def pair_images(
return canvas
def render_pair_photo(
data: bytes,
photo_position: int,
portrait_canvas: bool,
divider: int,
fill_mode: str,
) -> bytes:
"""Render one safe photo from an already composed pair without source I/O."""
if photo_position not in (0, 1):
raise ValueError("Invalid paired-photo position")
with open_image(data) as paired:
width, height = paired.size
length = height if portrait_canvas else width
first_length = max(1, (length - divider) // 2)
start, end = (
(0, first_length) if photo_position == 0
else (first_length + divider, length)
)
if not 0 <= start < end <= length:
raise ValueError("Paired photo is outside the rendered canvas")
box = (0, start, width, end) if portrait_canvas else (start, 0, end, height)
with paired.crop(box) as photo:
with render_image(photo, fill_mode, width, height) as rendered:
return encode_image(rendered)
def encode_image(img: Image.Image) -> bytes:
"""Encode a PIL image to a client-compatible JPEG or PNG.
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow", "PyNaCl>=1.5.0"],
"version": "1.10.0"
"version": "1.11.0"
}
@@ -20,6 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
AlbumCountSensor(entry, coordinator),
AlbumTitleSensor(entry, coordinator),
CacheUsageSensor(entry, coordinator),
HiddenPhotoCountSensor(entry, coordinator),
]
if coordinator.provider in ENRICHING_PROVIDERS:
# Diagnostic surface for the background enrichment pass (per-photo
@@ -68,6 +69,20 @@ class AlbumCountSensor(_BaseAlbumSensor):
return len(data.get("items", []))
class HiddenPhotoCountSensor(_BaseAlbumSensor):
_attr_icon = "mdi:eye-off"
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
self._attr_unique_id = f"{entry.entry_id}_hidden_photo_count"
self._attr_name = "Hidden photos"
coordinator.store.add_listener(self.async_write_ha_state)
@property
def native_value(self):
return len(self.coordinator.store.hidden_photo_ids)
class AlbumTitleSensor(_BaseAlbumSensor):
def __init__(self, entry: ConfigEntry, coordinator: AlbumCoordinator) -> None:
super().__init__(entry, coordinator)
@@ -1,3 +1,94 @@
hide_photo:
name: Hide photo
description: Exclude a photo from this slideshow without changing the source library. Paired slides require an explicit position or photo IDs.
fields:
entry_id:
name: Entry ID
required: true
selector:
text:
photo_ids:
name: Photo IDs
description: Optional list of IDs from displayed_photo_ids. These target the selected photos even if the slide changes.
selector:
object:
position:
name: Position
description: First is left or top; second is right or bottom. Omit for a single photo.
selector:
select:
options:
- first
- second
- both
frame_id:
name: Expected frame ID
description: Optional guard that rejects the action if the current frame has changed.
selector:
number:
min: 0
mode: box
undo_hide:
name: Undo hide
description: Restore the photos hidden by the last hide action for this slideshow.
fields:
entry_id:
name: Entry ID
required: true
selector:
text:
restore_photos:
name: Restore photos
description: Remove selected photo IDs from this slideshow's exclusion list.
fields:
entry_id:
name: Entry ID
required: true
selector:
text:
photo_ids:
name: Photo IDs
required: true
selector:
object:
restore_all_photos:
name: Restore all hidden photos
description: Clear this slideshow's exclusion list. Previously hidden photos become eligible for display again.
fields:
entry_id:
name: Entry ID
required: true
selector:
text:
list_hidden_photos:
name: List hidden photos
description: Return a page of hidden photo IDs and names without putting the full list into entity state.
fields:
entry_id:
name: Entry ID
required: true
selector:
text:
offset:
name: Offset
default: 0
selector:
number:
min: 0
mode: box
limit:
name: Page size
default: 50
selector:
number:
min: 1
max: 100
mode: box
next_slide:
name: Next slide
description: Advance the slideshow to the next image for a specific config entry.
+69 -1
View File
@@ -1,9 +1,13 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Callable
from typing import Callable, Iterable
from homeassistant.helpers.storage import Store
from .const import (
DOMAIN,
DEFAULT_SLIDE_INTERVAL,
DEFAULT_REFRESH_HOURS,
DEFAULT_FILL_MODE,
@@ -52,8 +56,72 @@ class SlideshowStore:
# the previous slide instantly across a camera reload.
last_frame: bytes | None = None
hidden_photo_ids: frozenset[str] = field(default_factory=frozenset, init=False)
last_hidden_photo_ids: tuple[str, ...] = field(default=(), init=False)
hidden_revision: int = field(default=0, init=False)
_hidden_storage: Store | None = field(default=None, init=False, repr=False)
_hidden_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
_listeners: list[Listener] = field(default_factory=list)
async def async_load_hidden_photos(self, hass, entry_id: str) -> None:
self._hidden_storage = Store(hass, 1, f"{DOMAIN}.{entry_id}.hidden")
data = await self._hidden_storage.async_load()
if data is None:
return
if not isinstance(data, dict):
raise ValueError("Invalid hidden-photo storage")
hidden = data.get("hidden", [])
last_hidden = data.get("last_hidden", [])
revision = data.get("revision", 0)
if type(revision) is not int or revision < 0:
raise ValueError("Invalid hidden-photo revision")
if any(
not isinstance(values, list)
or any(not isinstance(photo_id, str) or not photo_id for photo_id in values)
for values in (hidden, last_hidden)
):
raise ValueError("Invalid hidden-photo identifiers")
self.hidden_photo_ids = frozenset(hidden)
self.hidden_revision = revision
self.last_hidden_photo_ids = tuple(
dict.fromkeys(photo_id for photo_id in last_hidden if photo_id in self.hidden_photo_ids)
)
async def async_set_photos_hidden(
self, photo_ids: Iterable[str], *, hidden: bool
) -> bool:
requested = frozenset(photo_ids)
if any(not isinstance(photo_id, str) or not photo_id for photo_id in requested):
raise ValueError("Invalid photo identifier")
async with self._hidden_lock:
if self._hidden_storage is None:
raise RuntimeError("Photo exclusions have not been loaded")
updated = (
self.hidden_photo_ids | requested
if hidden
else self.hidden_photo_ids - requested
)
if updated == self.hidden_photo_ids:
return False
last_hidden = (
tuple(sorted(updated - self.hidden_photo_ids))
if hidden
else tuple(photo_id for photo_id in self.last_hidden_photo_ids if photo_id in updated)
)
await self._hidden_storage.async_save(
{
"hidden": sorted(updated),
"last_hidden": list(last_hidden),
"revision": self.hidden_revision + 1,
}
)
self.hidden_photo_ids = updated
self.last_hidden_photo_ids = last_hidden
self.hidden_revision += 1
self.last_frame = None
self.notify()
return True
def add_listener(self, cb: Listener) -> None:
if cb not in self._listeners:
self._listeners.append(cb)
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.10.0";
const VERSION = "1.11.0";
const ANIMATED_TRANSITIONS = [
"fade",
@@ -46,6 +46,19 @@ const TRANSITIONS = new Set(["random", "none", ...ANIMATED_TRANSITIONS]);
const FIT_MODES = new Set(["auto", "cover", "contain"]);
const PHOTO_CONTROL_OPTIONS = [
{ value: "off", label: "Off" },
{ value: "on_demand", label: "On demand" },
{ value: "always", label: "Always" },
];
function normalizePhotoControls(value) {
if (value == null || value === false) return "off";
if (value === true) return "always";
if (PHOTO_CONTROL_OPTIONS.some((option) => option.value === value)) return value;
throw new Error(`album-slideshow-card: unknown photo controls mode '${value}'`);
}
// Caption overlay (date / location / description). ``show`` is an ordered
// subset of these fields; ``position`` is one of a 3x3 anchor grid;
// ``date_format`` is one of the named presets below or a custom token string.
@@ -92,6 +105,436 @@ function isAlbumSlideshowCamera(state) {
);
}
class PhotoControls {
constructor(container, getHass, onActivity = () => {}, onNavigate = () => {}) {
this._getHass = getHass;
this._onActivity = onActivity;
this._onNavigate = onNavigate;
this._state = {};
this._busy = false;
this._root = container.attachShadow({ mode: "open" });
this._root.innerHTML = `
<style>
:host { display: block; max-width: 100%; font-family: var(--paper-font-body1_-_font-family, sans-serif); }
:host([hidden]) { display: none !important; }
button { font: inherit; color: inherit; cursor: pointer; border: 0; border-radius: 6px; background: var(--secondary-background-color, #eee); padding: 8px 12px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; }
button:hover { filter: brightness(.94); }
button:disabled { opacity: .4; cursor: default; }
button:focus-visible { outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; }
.toolbar { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); width: 300px; max-width: 100%; box-sizing: border-box; gap: 4px 12px; padding: 4px; border-radius: 6px; background: var(--card-background-color, #fff); color: var(--primary-text-color, #222); }
.control-group { display: flex; gap: 4px; justify-content: center; }
.icon { width: 44px; height: 44px; padding: 10px; flex: 0 0 44px; }
ha-icon { --mdc-icon-size: 22px; }
dialog { box-sizing: border-box; width: min(460px, calc(100vw - 32px)); max-width: calc(100vw - 32px); max-height: min(640px, calc(100dvh - 40px)); border: 1px solid var(--divider-color, #ddd); border-radius: 8px; padding: 16px; font-size: 14px; background: var(--card-background-color, #fff); color: var(--primary-text-color, #222); }
dialog::backdrop { background: rgba(0, 0, 0, .45); }
header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
h3 { margin: 0; font-size: 18px; letter-spacing: 0; }
.choices { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--divider-color, #ddd); }
.name { min-width: 0; overflow-wrap: anywhere; }
small { display: block; color: var(--secondary-text-color, #666); margin-top: 4px; }
footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.pagination { display: flex; gap: 8px; align-items: center; font-size: 13px; }
.error { margin: 8px 0; max-width: 320px; padding: 8px; background: var(--card-background-color, #fff); color: var(--error-color, #b71c1c); overflow-wrap: anywhere; }
[hidden] { display: none !important; }
</style>
<div class="toolbar">
<div class="control-group" role="group" aria-label="Slideshow navigation">
<button type="button" class="icon" id="previous" title="Previous slide" aria-label="Previous slide"><ha-icon icon="mdi:skip-previous"></ha-icon></button>
<button type="button" class="icon" id="pause" title="Pause slideshow" aria-label="Pause slideshow"><ha-icon icon="mdi:pause"></ha-icon></button>
<button type="button" class="icon" id="next" title="Next slide" aria-label="Next slide"><ha-icon icon="mdi:skip-next"></ha-icon></button>
</div>
<div class="control-group" role="group" aria-label="Photo visibility">
<button type="button" class="icon" id="hide" title="Hide photo" aria-label="Hide photo"><ha-icon icon="mdi:eye-off"></ha-icon></button>
<button type="button" class="icon" id="undo" title="Undo hide" aria-label="Undo hide"><ha-icon icon="mdi:undo"></ha-icon></button>
<button type="button" class="icon" id="manage" title="Hidden photos" aria-label="Hidden photos"><ha-icon icon="mdi:image-off-outline"></ha-icon></button>
</div>
</div>
<div id="error" class="error" role="alert" hidden></div>
<dialog aria-labelledby="title">
<header><h3 id="title">Hidden photos</h3><button type="button" class="icon" id="close" title="Close" aria-label="Close"><ha-icon icon="mdi:close"></ha-icon></button></header>
<div id="dialog-error" class="error" role="alert" hidden></div>
<div id="content"></div>
</dialog>
`;
this._dialog = this._root.querySelector("dialog");
this._dialog.addEventListener("close", () => this._onActivity());
container.addEventListener("click", (event) => event.stopPropagation());
this._root.getElementById("previous").addEventListener("click", () => this._navigate("previous_slide"));
this._root.getElementById("next").addEventListener("click", () => this._navigate("next_slide"));
this._root.getElementById("pause").addEventListener("click", () => this._togglePause());
this._root.getElementById("hide").addEventListener("click", () => this._hide());
this._root.getElementById("undo").addEventListener("click", () => this._run("undo_hide"));
this._root.getElementById("manage").addEventListener("click", () => this._showHidden(0));
this._root.getElementById("close").addEventListener("click", () => this._dialog.close());
}
get active() {
return this._busy || this._dialog.open;
}
close() {
this._dialog.close();
}
update(state) {
if (this._state.entryId && this._state.entryId !== state.entryId) {
this._dialog.close();
}
this._state = { ...state, photoIds: [...(state.photoIds || [])] };
const unavailable = this._busy || !state.entryId;
this._root.getElementById("previous").disabled = unavailable || !state.canPrevious;
this._root.getElementById("next").disabled = unavailable || !state.canNext;
const pause = this._root.getElementById("pause");
pause.disabled = unavailable || typeof state.paused !== "boolean";
pause.title = state.paused ? "Resume slideshow" : "Pause slideshow";
pause.setAttribute("aria-label", pause.title);
pause.querySelector("ha-icon").setAttribute("icon", state.paused ? "mdi:play" : "mdi:pause");
this._root.getElementById("hide").disabled = this._busy || !state.entryId || !this._state.photoIds.some(Boolean);
this._root.getElementById("undo").disabled = this._busy || !state.entryId || !state.canUndo;
this._root.getElementById("manage").disabled = this._busy || !state.entryId;
this._root.getElementById("manage").title = `Hidden photos (${state.hiddenCount || 0})`;
}
async _call(service, data = {}, entryId = this._state.entryId) {
if (!entryId) throw new Error("This slideshow does not support these controls");
const result = await this._getHass().callWS({
type: "call_service",
domain: "album_slideshow",
service,
service_data: { ...data, entry_id: entryId },
...(service === "list_hidden_photos" ? { return_response: true } : {}),
});
return result?.response;
}
async _navigate(service) {
if (this._busy || !this._state.entryId) return false;
const entryId = this._state.entryId;
this._onNavigate?.("start", entryId);
const success = await this._run(service, {}, entryId);
this._onNavigate?.(success ? "complete" : "failed", entryId);
return success;
}
async _togglePause() {
const { entryId, paused } = this._state;
if (!entryId || typeof paused !== "boolean") return false;
return this._runAction(async () => {
const hass = this._getHass();
const registry = await hass.callWS({ type: "config/entity_registry/list" });
const entity = registry.find((candidate) =>
candidate.config_entry_id === entryId &&
candidate.unique_id === `${entryId}_paused` &&
candidate.entity_id.startsWith("switch.") &&
!candidate.disabled_by,
);
if (!entity) throw new Error("The pause switch is unavailable for this slideshow");
await hass.callWS({
type: "call_service",
domain: "switch",
service: paused ? "turn_off" : "turn_on",
service_data: { entity_id: entity.entity_id },
});
});
}
_error(message = "") {
for (const id of ["error", "dialog-error"]) {
const element = this._root.getElementById(id);
element.textContent = message;
element.hidden = !message;
}
}
async _run(service, data = {}, entryId = this._state.entryId) {
return this._runAction(() => this._call(service, data, entryId));
}
async _runAction(action) {
if (this._busy) return false;
this._busy = true;
this._onActivity?.();
this._error();
this.update(this._state);
const buttons = [...this._dialog.querySelectorAll("#content button")].map((button) => ({ button, disabled: button.disabled }));
buttons.forEach(({ button }) => { button.disabled = true; });
try {
await action();
this._dialog.close();
return true;
} catch (error) {
this._error(error.message || "Photo action failed");
return false;
} finally {
this._busy = false;
this.update(this._state);
buttons.forEach(({ button, disabled }) => { button.disabled = disabled; });
this._onActivity?.();
}
}
_button(label, icon, action, iconOnly = false) {
const button = document.createElement("button");
button.type = "button";
button.title = label;
button.setAttribute("aria-label", label);
if (iconOnly) button.className = "icon";
const symbol = document.createElement("ha-icon");
symbol.setAttribute("icon", icon);
button.appendChild(symbol);
if (!iconOnly) button.appendChild(document.createTextNode(label));
button.addEventListener("click", action);
return button;
}
_open(title) {
this._viewGeneration = (this._viewGeneration || 0) + 1;
this._error();
this._root.getElementById("title").textContent = title;
const content = this._root.getElementById("content");
content.replaceChildren();
if (!this._dialog.open) this._dialog.showModal();
this._onActivity?.();
return content;
}
_hide() {
const { photoIds, orientation, entryId } = this._state;
if (photoIds.length === 1 && photoIds[0]) {
this._run("hide_photo", { photo_ids: [...photoIds] }, entryId);
return;
}
const content = this._open("Hide photo");
content.className = "choices";
const labels = orientation === "vertical" ? ["Hide top photo", "Hide bottom photo"] : ["Hide left photo", "Hide right photo"];
photoIds.forEach((photoId, index) => {
const button = this._button(labels[index], "mdi:eye-off", () => this._run("hide_photo", { photo_ids: [photoId] }, entryId));
button.disabled = !photoId;
content.appendChild(button);
});
const both = this._button("Hide both photos", "mdi:eye-off", () => this._run("hide_photo", { photo_ids: [...photoIds] }, entryId));
both.disabled = photoIds.length !== 2 || photoIds.some((photoId) => !photoId);
content.appendChild(both);
}
async _showHidden(offset) {
const entryId = this._state.entryId;
const content = this._open("Hidden photos");
const generation = this._viewGeneration;
content.className = "";
content.textContent = "Loading...";
try {
const page = await this._call("list_hidden_photos", { offset, limit: 25 }, entryId);
if (generation !== this._viewGeneration || this._state.entryId !== entryId || !this._dialog.open) return;
if (offset && !page.photos.length) {
await this._showHidden(Math.max(0, offset - 25));
return;
}
content.replaceChildren();
if (!page.photos.length) content.textContent = "No hidden photos";
for (const photo of page.photos) {
const row = document.createElement("div");
row.className = "row";
const name = document.createElement("span");
name.className = "name";
name.textContent = photo.name;
if (!photo.in_album) {
const status = document.createElement("small");
status.textContent = "Not in the current album";
name.appendChild(status);
}
row.append(name, this._button(`Restore ${photo.name}`, "mdi:restore", async () => {
if (await this._run("restore_photos", { photo_ids: [photo.photo_id] }, entryId)) await this._showHidden(offset);
}, true));
content.appendChild(row);
}
if (!page.total) return;
const footer = document.createElement("footer");
footer.appendChild(this._button("Restore all", "mdi:restore", () => {
const confirm = this._open("Restore all hidden photos?");
confirm.className = "choices";
confirm.append(
this._button("Restore all", "mdi:restore", async () => {
if (await this._run("restore_all_photos", {}, entryId)) await this._showHidden(0);
}),
this._button("Cancel", "mdi:close", () => this._showHidden(offset)),
);
}));
const pagination = document.createElement("div");
pagination.className = "pagination";
const previous = this._button("Previous page", "mdi:chevron-left", () => this._showHidden(Math.max(0, offset - 25)), true);
const next = this._button("Next page", "mdi:chevron-right", () => this._showHidden(offset + 25), true);
previous.disabled = offset === 0;
next.disabled = offset + page.photos.length >= page.total;
pagination.append(previous, document.createTextNode(`${offset + 1}-${offset + page.photos.length} of ${page.total}`), next);
footer.appendChild(pagination);
content.appendChild(footer);
} catch (error) {
if (generation !== this._viewGeneration || this._state.entryId !== entryId) return;
content.textContent = "";
this._error(error.message || "Could not load hidden photos");
}
}
}
class PhotoControlsReveal {
constructor(card, container, controls, resume) {
this._card = card;
this._container = container;
this._controls = controls;
this._resume = resume;
this._visible = false;
this._pointer = null;
this._listeners = [];
container.hidden = true;
this._listen(card, "pointerenter", (event) => {
if (event.pointerType === "mouse") this.show();
});
this._listen(card, "pointerleave", (event) => {
this._cancelPress();
if (event.pointerType === "mouse" && !this._keyboardFocused()) this.hide();
else this.activity();
});
this._listen(card, "pointerdown", (event) => this._pointerDown(event));
this._listen(card, "pointermove", (event) => this._pointerMove(event));
this._listen(card, "pointerup", () => { this._cancelPress(); this.activity(); });
this._listen(card, "pointercancel", () => { this._cancelPress(); this.activity(); });
this._listen(card, "click", (event) => {
if (event.composedPath().includes(container)) {
this._suppressClick = false;
this.activity();
} else if (this._suppressClick && event.detail !== 0) {
this._suppressClick = false;
event.preventDefault();
event.stopImmediatePropagation();
}
}, true);
this._listen(card, "contextmenu", (event) => {
if (this._suppressClick || (this._pointer && this._pointer.type !== "mouse")) {
event.preventDefault();
event.stopPropagation();
this._suppressClick = true;
this.show();
}
});
this._listen(card, "focusin", (event) => {
if (!this._ignoreFocus && event.composedPath()[0]?.matches?.(":focus-visible")) this.show();
else this.activity();
});
this._listen(card, "focusout", () => this.activity());
this._listen(card, "keydown", (event) => this._keyDown(event));
this._listen(card.ownerDocument, "pointerdown", (event) => {
if (!event.composedPath().includes(card)) this.hide();
}, true);
}
get holding() {
return !this._disposed && (this._visible || this._pointer !== null);
}
_listen(target, type, listener, capture = false) {
target.addEventListener(type, listener, capture);
this._listeners.push(() => target.removeEventListener(type, listener, capture));
}
show() {
if (this._disposed) return;
this._visible = true;
this._container.hidden = false;
this.activity();
}
hide() {
if (this._disposed || this._controls.active) return;
const wasHolding = this.holding;
this._visible = false;
this._container.hidden = true;
clearTimeout(this._idleTimer);
this._idleTimer = null;
this._cancelPress(false);
if (wasHolding) this._resume();
}
activity() {
if (this._disposed || !this._visible) return;
clearTimeout(this._idleTimer);
this._idleTimer = setTimeout(() => {
this._idleTimer = null;
if (this._controls.active || this._pointer || this._keyboardFocused()) return;
this.hide();
}, 5000);
}
_keyboardFocused() {
return this._card.matches(":focus-visible") || Boolean(this._container.shadowRoot?.querySelector(":focus-visible"));
}
_pointerDown(event) {
if (event.composedPath().includes(this._container)) {
this.activity();
return;
}
this._suppressClick = false;
if (event.isPrimary === false || event.button > 0) {
this._cancelPress();
return;
}
this._cancelPress(false);
this._pointer = { id: event.pointerId, type: event.pointerType, x: event.clientX, y: event.clientY };
this._pressTimer = setTimeout(() => {
this._pressTimer = null;
if (!this._pointer || this._disposed) return;
this._suppressClick = true;
this.show();
}, 500);
}
_pointerMove(event) {
if (this._pointer) {
if (event.pointerId === this._pointer.id && Math.hypot(event.clientX - this._pointer.x, event.clientY - this._pointer.y) > 10) this._cancelPress();
} else if (event.pointerType === "mouse") {
this.show();
}
}
_cancelPress(resume = true) {
const wasPressed = this._pointer !== null;
clearTimeout(this._pressTimer);
this._pressTimer = null;
this._pointer = null;
if (resume && wasPressed && !this._visible && !this._disposed) this._resume();
}
_keyDown(event) {
if (event.key === "Escape" && this._visible && !this._controls.active) {
event.preventDefault();
event.stopPropagation();
this.hide();
this._ignoreFocus = true;
this._card.focus({ preventScroll: true });
this._ignoreFocus = false;
} else if ((event.key === "Enter" || event.key === " ") && event.composedPath()[0] === this._card) {
event.preventDefault();
event.stopPropagation();
this.show();
this._container.shadowRoot?.querySelector("button:not([disabled])")?.focus();
} else {
this.activity();
}
}
dispose() {
this._disposed = true;
clearTimeout(this._idleTimer);
this._cancelPress(false);
this._listeners.forEach((remove) => remove());
this._listeners = [];
this._controls.close();
this._visible = false;
}
}
// The card class is built lazily by a factory so the base class can be
// resolved from the *live* ``window.HTMLElement`` at registration time.
// See ``defineAlbumSlideshowCards`` for why this matters with the
@@ -135,6 +578,13 @@ function createAlbumSlideshowCardClass(Base) {
// schedules a deferred swap that runs once the hold expires.
this._holdSwapsUntil = 0;
this._holdSwapTimer = null;
this._loadGeneration = 0;
this._displayedPhotoIds = [];
this._displayedFrameId = null;
this._navigationRequest = null;
this._navigationTimer = null;
this._photoEntryId = null;
this._hiddenRevision = undefined;
}
setConfig(config) {
@@ -164,6 +614,7 @@ function createAlbumSlideshowCardClass(Base) {
// Empty/missing background means inherit theme.
background: typeof config.background === "string" ? config.background : "",
tap_action: config.tap_action === "more-info" ? "more-info" : "none",
photo_controls: normalizePhotoControls(config.photo_controls),
// Number of seconds the card freezes its visible slide after a
// tap, so the more-info dialog can settle without the slideshow
// marching forward beneath it. Set to 0 to disable.
@@ -232,7 +683,21 @@ function createAlbumSlideshowCardClass(Base) {
if (!this._rendered) {
this._renderShell();
this._rendered = true;
} else {
this._setupPhotoControlsReveal();
}
this._maybeSwap();
}
disconnectedCallback() {
this._cancelControlNavigation();
this._controlsReveal?.dispose();
this._controlsReveal = null;
clearTimeout(this._holdSwapTimer);
this._holdSwapTimer = null;
this._loadGeneration += 1;
this._lastFrameId = null;
this._lastEntityPicture = null;
}
set hass(hass) {
@@ -257,6 +722,14 @@ function createAlbumSlideshowCardClass(Base) {
_renderShell() {
const c = this._config;
this._cancelControlNavigation();
this._controlsReveal?.dispose();
this._controlsReveal = null;
this._loadGeneration += 1;
this._displayedPhotoIds = [];
this._displayedFrameId = null;
this._photoEntryId = null;
this._hiddenRevision = undefined;
const aspect = c.aspect_ratio === "auto" ? "auto" : c.aspect_ratio;
// When the user did not set ``background`` we fall through to the
// theme's --ha-card-background, so the card naturally inherits the
@@ -275,6 +748,7 @@ function createAlbumSlideshowCardClass(Base) {
position: relative;
padding: 0;
}
ha-card:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; }
.stage {
position: absolute;
inset: 0;
@@ -283,6 +757,7 @@ function createAlbumSlideshowCardClass(Base) {
background: ${stageBg};
border-radius: inherit;
overflow: hidden;
${c.photo_controls === "on_demand" ? "-webkit-touch-callout: none; user-select: none;" : ""}
}
.blur-bg {
position: absolute;
@@ -347,6 +822,7 @@ function createAlbumSlideshowCardClass(Base) {
font-family: var(--paper-font-body1_-_font-family, sans-serif);
}
.cap-line { font-weight: inherit; }
#photo-controls { position: absolute; right: 8px; max-width: calc(100% - 16px); ${c.caption?.position.startsWith("top") ? "bottom" : "top"}: 8px; z-index: 3; }
.cap-box.cap-shadow {
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.9),
@@ -363,13 +839,66 @@ function createAlbumSlideshowCardClass(Base) {
<div class="captions" id="captions" aria-hidden="true"></div>
<div class="placeholder" id="placeholder">Waiting for first frame...</div>
</div>
<div id="photo-controls" ${c.photo_controls === "always" ? "" : "hidden"}></div>
</ha-card>
`;
this._photoControls = new PhotoControls(
this.shadowRoot.getElementById("photo-controls"), () => this._hass,
() => this._controlsReveal?.activity(),
(phase, entryId) => this._controlNavigation(phase, entryId),
);
const card = this.shadowRoot.querySelector("ha-card");
if (this._config.tap_action === "more-info") {
card.addEventListener("click", () => this._fireMoreInfo());
card.style.cursor = "pointer";
}
this._setupPhotoControlsReveal();
}
_setupPhotoControlsReveal() {
this._controlsReveal?.dispose();
this._controlsReveal = null;
const card = this.shadowRoot.querySelector("ha-card");
const container = this.shadowRoot.getElementById("photo-controls");
container.hidden = this._config.photo_controls !== "always";
if (this._config.photo_controls === "on_demand") {
card.tabIndex = 0;
card.setAttribute("role", "group");
card.setAttribute("aria-label", "Slideshow photo controls");
if (this.isConnected) {
this._controlsReveal = new PhotoControlsReveal(card, container, this._photoControls, () => this._maybeSwap());
}
}
}
_cancelControlNavigation() {
clearTimeout(this._navigationTimer);
this._navigationTimer = null;
this._navigationRequest = null;
}
_controlNavigation(phase, entryId) {
const attrs = this._hass?.states[this._config.entity]?.attributes || {};
if (attrs.entry_id !== entryId) return;
if (phase === "start") {
this._cancelControlNavigation();
this._navigationRequest = { entryId, frameId: attrs.frame_id, pending: true };
this._holdSwapsUntil = 0;
clearTimeout(this._holdSwapTimer);
this._holdSwapTimer = null;
} else if (this._navigationRequest?.entryId === entryId) {
if (phase === "failed") {
this._cancelControlNavigation();
} else {
this._navigationRequest.pending = false;
if (this._displayedFrameId !== this._navigationRequest.frameId && this._displayedFrameId === attrs.frame_id) {
this._cancelControlNavigation();
} else {
this._navigationTimer = setTimeout(() => this._cancelControlNavigation(), 5000);
}
}
}
this._maybeSwap();
}
_transitionStyles() {
@@ -439,6 +968,18 @@ function createAlbumSlideshowCardClass(Base) {
this._setPlaceholder(`Entity not found: ${this._config.entity}`);
return;
}
const attrs = state.attributes || {};
if (this._hiddenRevision !== undefined && attrs.hidden_revision !== this._hiddenRevision) {
this._clearDisplayedPhotos();
}
this._hiddenRevision = attrs.hidden_revision;
this._refreshPhotoControls(attrs);
if (attrs.empty_reason || (Array.isArray(attrs.displayed_photo_ids) && !attrs.displayed_photo_ids.length)) {
this._clearDisplayedPhotos();
this._setPlaceholder(attrs.empty_reason === "all_hidden" ? "All photos hidden" : attrs.empty_reason ? "No matching photos" : "Preparing next photo...");
return;
}
if (this._controlsReveal?.holding && this._displayedPhotoIds.length && !this._navigationRequest) return;
// Hold visual swaps for the configured grace period after a tap.
// The state cursor (`_lastFrameId`/`_lastEntityPicture`) is left
// untouched during the hold; once the hold expires we re-enter
@@ -454,7 +995,6 @@ function createAlbumSlideshowCardClass(Base) {
}
return;
}
const attrs = state.attributes || {};
// ``frame_id`` increments on every slide commit; that's our primary
// "new frame ready" signal. The integration also embeds frame_id in
// ``entity_picture`` so that HA core surfaces (more-info, picture
@@ -495,15 +1035,79 @@ function createAlbumSlideshowCardClass(Base) {
description: attrs.description,
}
: null;
this._loadAndSwap(url, fit, blurBackdrop, captionData);
const photoData = {
photoIds: [...(attrs.displayed_photo_ids || [])],
entryId: attrs.entry_id,
orientation: attrs.pair_orientation,
frameId,
hiddenRevision: attrs.hidden_revision,
entityId: this._config.entity,
};
this._loadAndSwap(url, fit, blurBackdrop, captionData, photoData);
}
_loadAndSwap(url, fit, blurBackdrop, captionData) {
_clearDisplayedPhotos() {
this._loadGeneration += 1;
this._displayedPhotoIds = [];
this._displayedFrameId = null;
this._lastFrameId = null;
this._lastEntityPicture = null;
this._holdSwapsUntil = 0;
if (this._holdSwapTimer) clearTimeout(this._holdSwapTimer);
this._holdSwapTimer = null;
for (const id of ["a", "b", "blur-a", "blur-b"]) {
const image = this.shadowRoot.getElementById(id);
image.removeAttribute("src");
image.classList.remove("show", "exit", "enter");
}
this.shadowRoot.getElementById("captions").replaceChildren();
this._refreshPhotoControls(this._hass.states[this._config.entity]?.attributes || {});
}
_refreshPhotoControls(attrs) {
this._photoControls?.update({
entryId: attrs.entry_id,
photoIds: this._displayedPhotoIds,
orientation: this._photoOrientation,
hiddenCount: attrs.hidden_photo_count,
canUndo: attrs.undo_hide_available,
canPrevious: attrs.previous_frames_cached > 0,
canNext: attrs.media_count > 0,
paused: attrs.paused,
});
}
_loadAndSwap(url, fit, blurBackdrop, captionData, photoData) {
const generation = ++this._loadGeneration;
// Pre-decode the new image so the swap is instant.
const next = new Image();
next.decoding = "async";
next.onload = () => this._performSwap(url, fit, blurBackdrop, captionData);
next.onerror = () => this._setPlaceholder("Failed to load slide");
next.onload = () => {
if (generation !== this._loadGeneration) return;
if (this._controlsReveal?.holding && this._displayedPhotoIds.length && !this._navigationRequest) {
this._lastFrameId = null;
this._lastEntityPicture = null;
return;
}
const attrs = this._hass.states[this._config.entity]?.attributes || {};
if (photoData.entityId !== this._config.entity || (attrs.frame_id ?? null) !== photoData.frameId || attrs.hidden_revision !== photoData.hiddenRevision) {
this._lastFrameId = null;
this._maybeSwap();
return;
}
this._displayedPhotoIds = [...photoData.photoIds];
this._displayedFrameId = photoData.frameId;
this._photoEntryId = photoData.entryId;
this._photoOrientation = photoData.orientation;
this._performSwap(url, fit, blurBackdrop, captionData);
if (this._navigationRequest && !this._navigationRequest.pending && photoData.frameId !== this._navigationRequest.frameId) {
this._cancelControlNavigation();
}
this._refreshPhotoControls(attrs);
};
next.onerror = () => {
if (generation === this._loadGeneration) this._setPlaceholder("Failed to load slide");
};
next.src = url;
}
@@ -934,6 +1538,7 @@ const CAPTION_DATE_FORMAT_OPTIONS = [
];
const DEFAULTS = {
photo_controls: "off",
transition: "random",
duration: 600,
easing: "ease-in-out",
@@ -1036,6 +1641,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
// see entity state updates.
const form = this.shadowRoot.querySelector("ha-form");
if (form) form.hass = hass;
this._updatePhotoControls();
// Re-run a full update when the camera set changes (warning box) or
// when any surfaced integration entity changed state, so the live
// controls stay in sync with the integration.
@@ -1277,6 +1883,10 @@ function createAlbumSlideshowCardEditorClass(Base) {
title: "Interaction",
icon: "mdi:gesture-tap",
schema: [
{
name: "photo_controls",
selector: { select: { mode: "dropdown", options: PHOTO_CONTROL_OPTIONS } },
},
{
name: "tap_action",
selector: { select: { mode: "dropdown", options: TAP_OPTIONS } },
@@ -1364,6 +1974,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
const c = this._config || {};
return {
entity: c.entity || "",
photo_controls: normalizePhotoControls(c.photo_controls),
transition: c.transition || DEFAULTS.transition,
duration: c.duration != null ? Number(c.duration) : DEFAULTS.duration,
easing: c.easing || DEFAULTS.easing,
@@ -1441,6 +2052,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
background: "Background (optional)",
tap_action: "Tap action",
tap_pause_seconds: "Tap pause (seconds)",
photo_controls: "Photo controls",
caption_enabled: "Show caption overlay",
caption_show: "Show",
caption_position: "Position",
@@ -1517,12 +2129,16 @@ function createAlbumSlideshowCardEditorClass(Base) {
<div class="info-slot"></div>
<ha-form></ha-form>
<div class="actions" hidden></div>
<div class="photo-controls"></div>
</div>
`;
const form = this.shadowRoot.querySelector("ha-form");
form.computeLabel = this._computeLabel;
form.computeHelper = this._computeHelper;
form.addEventListener("value-changed", (ev) => this._valueChanged(ev));
this._photoControls = new PhotoControls(
this.shadowRoot.querySelector(".photo-controls"), () => this._hass,
);
this._rendered = true;
this._update();
}
@@ -1553,12 +2169,30 @@ function createAlbumSlideshowCardEditorClass(Base) {
}
this._renderActions();
this._updatePhotoControls();
}
_updatePhotoControls() {
if (!this._photoControls) return;
const attrs = this._hass?.states[this._config?.entity]?.attributes || {};
this.shadowRoot.querySelector(".photo-controls").hidden = !attrs.entry_id;
this._photoControls.update({
entryId: attrs.entry_id,
photoIds: attrs.displayed_photo_ids,
orientation: attrs.pair_orientation,
hiddenCount: attrs.hidden_photo_count,
canUndo: attrs.undo_hide_available,
canPrevious: attrs.previous_frames_cached > 0,
canNext: attrs.media_count > 0,
paused: attrs.paused,
});
}
_renderActions() {
const wrap = this.shadowRoot.querySelector(".actions");
if (!wrap) return;
if (!this._hasActions()) {
const hasToolbarNavigation = !!this._hass?.states[this._config?.entity]?.attributes?.entry_id;
if (!this._hasActions() || (hasToolbarNavigation && !this._siblings.refresh_button)) {
wrap.hidden = true;
wrap.innerHTML = "";
return;
@@ -1568,8 +2202,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
wrap.innerHTML = `
<div class="actions-title">Actions</div>
<div class="actions-row">
${s.previous_button ? `<button class="act" data-act="previous">Previous slide</button>` : ""}
${s.next_button ? `<button class="act" data-act="next">Next slide</button>` : ""}
${s.previous_button && !hasToolbarNavigation ? `<button class="act" data-act="previous">Previous slide</button>` : ""}
${s.next_button && !hasToolbarNavigation ? `<button class="act" data-act="next">Next slide</button>` : ""}
${s.refresh_button ? `<button class="act" data-act="refresh">Refresh album</button>` : ""}
</div>
`;
@@ -1651,6 +2285,9 @@ function createAlbumSlideshowCardEditorClass(Base) {
const n = { type: "custom:album-slideshow-card" };
const photoControls = normalizePhotoControls(data.photo_controls);
if (photoControls === "always") n.photo_controls = true;
if (photoControls === "on_demand") n.photo_controls = "on_demand";
if (data.entity) n.entity = data.entity;
const t = data.transition || DEFAULTS.transition;