59 files
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user