Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4004d987f1 | ||
|
|
ad634c1b84 |
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"pid": 71, "version": 1, "ha_version": "2026.9.3", "start_ts": 1790211396.172996}
|
||||
{"pid": 72, "version": 1, "ha_version": "2026.9.3", "start_ts": 1790309385.7261584}
|
||||
+40
-40
@@ -26,7 +26,7 @@
|
||||
},
|
||||
{
|
||||
"id": "ee7be1b18ae84060a5d515640f6e5739",
|
||||
"url": "/seatemperatures_frontend/sea-temperatures-card.js?v=3.3.0",
|
||||
"url": "/seatemperatures_frontend/sea-temperatures-card.js?v=3.3.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -81,117 +81,117 @@
|
||||
},
|
||||
{
|
||||
"id": "88b90f2b1d6b45f0b44bee786ee1a525",
|
||||
"url": "/taskmate/taskmate-attr-resolver.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-attr-resolver.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "0796c92931dd458e81dc9c2677248274",
|
||||
"url": "/taskmate/taskmate-localize.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-localize.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d2e281920ac7409583eafff1f1db69bd",
|
||||
"url": "/taskmate/taskmate-design.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-design.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "9eae9632f0734480be5edd784b030673",
|
||||
"url": "/taskmate/taskmate-badges-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-badges-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "5df49cd8a4eb420497b5df22cb31362c",
|
||||
"url": "/taskmate/taskmate-child-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-child-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "b44c87d0d74f468cb96c5eabc340f9ee",
|
||||
"url": "/taskmate/taskmate-rewards-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-rewards-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "787a96f9c30548708165a48457c99feb",
|
||||
"url": "/taskmate/taskmate-approvals-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-approvals-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "84031597de424ad68e7c2bc66d06b72e",
|
||||
"url": "/taskmate/taskmate-points-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-points-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "02613225844746b699bfbfac9642eca2",
|
||||
"url": "/taskmate/taskmate-reorder-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-reorder-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "7a06a07e17ba4b31b712bc91b77360b4",
|
||||
"url": "/taskmate/taskmate-overview-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-overview-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "08b42b53b33b4fa88db932455cf8d5aa",
|
||||
"url": "/taskmate/taskmate-activity-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-activity-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "15907eff51784acdba88ff694b574509",
|
||||
"url": "/taskmate/taskmate-streak-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-streak-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39",
|
||||
"url": "/taskmate/taskmate-weekly-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-weekly-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "289ce5943f5a4972a593697db4c96ece",
|
||||
"url": "/taskmate/taskmate-graph-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-graph-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "d2904acee4c74a398dcc7b996c23e7e7",
|
||||
"url": "/taskmate/taskmate-reward-progress-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-reward-progress-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "46d378736fd54a26be253f2dfb27202e",
|
||||
"url": "/taskmate/taskmate-leaderboard-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-leaderboard-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "db11ae36457842fa826c7991e1d9c6f4",
|
||||
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "ce85f301b59a412e9abfe5ffd51cb9a2",
|
||||
"url": "/taskmate/taskmate-penalties-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-penalties-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "1959e1629de2422daa8adaf87069c288",
|
||||
"url": "/taskmate/taskmate-bonuses-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-bonuses-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "2548524b269c4959bc7e0e967df943af",
|
||||
"url": "/taskmate/taskmate-points-display-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-points-display-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "36ada49368db4187a283fbbbef45454f",
|
||||
"url": "/taskmate/taskmate-calendar-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-calendar-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "67c32687acd2497b90030ef141f4b6db",
|
||||
"url": "/taskmate/taskmate-photo-gallery-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-photo-gallery-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "6f0c8348a91f4b74a9b846ab3f62621f",
|
||||
"url": "/taskmate/taskmate-family-goal-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-family-goal-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -201,7 +201,7 @@
|
||||
},
|
||||
{
|
||||
"id": "4d1a17aa96b74064af713fc5e9334862",
|
||||
"url": "/hacsfiles/ha-treemap-card/treemap-card.js?hacstag=11141174680153",
|
||||
"url": "/hacsfiles/ha-treemap-card/treemap-card.js?hacstag=11141174680160",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -231,7 +231,7 @@
|
||||
},
|
||||
{
|
||||
"id": "449ff43140654e4bbe2c7b2d361dfacc",
|
||||
"url": "/hacsfiles/clock-weather-card/clock-weather-card.js?hacstag=522634019294",
|
||||
"url": "/hacsfiles/clock-weather-card/clock-weather-card.js?hacstag=522634019295",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -276,7 +276,7 @@
|
||||
},
|
||||
{
|
||||
"id": "73dbd2de03de4530b451a55bfe6c46cf",
|
||||
"url": "/taskmate/taskmate-routine-card.js?v=5.7.0",
|
||||
"url": "/taskmate/taskmate-routine-card.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
@@ -311,27 +311,27 @@
|
||||
},
|
||||
{
|
||||
"id": "1a4939ce1d2b47f89502176d3c5ac6e7",
|
||||
"url": "/taskmate/taskmate-sounds.js?v=5.7.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "2dfca9e286bb4c5fb92ac3a46332fb3b",
|
||||
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.10.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "f7036d587eed4a2ea6d30c5271d7a78e",
|
||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||
"url": "/taskmate/taskmate-sounds.js?v=5.8.0",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "f4b0f22e5ce74727afbde9d7168a141e",
|
||||
"url": "/ha_creality_ws/k_printer_card.js?v=1790211801",
|
||||
"url": "/ha_creality_ws/k_printer_card.js?v=1790309423",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "5e4ca4c959f04b4c9b1dd8da0c4cb3b7",
|
||||
"url": "/ha_creality_ws/k_cfs_card.js?v=1790211801",
|
||||
"url": "/ha_creality_ws/k_cfs_card.js?v=1790309423",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "0415d839dea24062b977830fdd85f135",
|
||||
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
|
||||
"type": "module"
|
||||
},
|
||||
{
|
||||
"id": "038f9bf71bde42548113bc0355fa4ec0",
|
||||
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.11.0",
|
||||
"type": "module"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"documentation": "https://github.com/slashback100/presence_simulation",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/slashback100/presence_simulation/issues",
|
||||
"version": "5.3"
|
||||
"version": "6.0"
|
||||
}
|
||||
@@ -27,6 +27,10 @@ start:
|
||||
name: Set light brightness
|
||||
description: Set a specific brightness fot lights that allows it
|
||||
example: 75
|
||||
history_end:
|
||||
name: End of source history
|
||||
description: ISO 8601 date and time at which source history ends. The selected history is replayed cyclically from that moment.
|
||||
example: "2026-08-09T00:00:00+02:00"
|
||||
stop:
|
||||
description: Stop the presence simulation
|
||||
name: Presence simulation stop
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"restore": "Restore state after simulation",
|
||||
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
|
||||
"unavailable_as_off": "Consider Unavailable status as Off",
|
||||
"brightness": "Brightness to be used if light allows the feature"
|
||||
"brightness": "Brightness to be used if light allows the feature",
|
||||
"history_end_date": "End date of source history",
|
||||
"history_end_time": "End time of source history"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +36,9 @@
|
||||
"restore": "Restore state after simulation",
|
||||
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
|
||||
"unavailable_as_off": "Consider Unavailable status as Off",
|
||||
"brightness": "Brightness to be used if light allows the feature"
|
||||
"brightness": "Brightness to be used if light allows the feature",
|
||||
"history_end_date": "End date of source history",
|
||||
"history_end_time": "End time of source history"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"restore": "Restore state after simulation",
|
||||
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
|
||||
"unavailable_as_off": "Consider Unavailable status as Off",
|
||||
"brightness": "Brightness to be used if light allows the feature"
|
||||
"brightness": "Brightness to be used if light allows the feature",
|
||||
"history_end_date": "End date of historic",
|
||||
"history_end_time": "End time of historic"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +37,9 @@
|
||||
"restore": "Restore state after simulation",
|
||||
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
|
||||
"unavailable_as_off": "Consider Unavailable status as Off",
|
||||
"brightness": "Brightness to be used if light allows the feature"
|
||||
"brightness": "Brightness to be used if light allows the feature",
|
||||
"history_end_date": "End date of historic",
|
||||
"history_end_time": "End time of historic"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"restore": "Restore state after simulation",
|
||||
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
|
||||
"unavailable_as_off": "Consider Unavailable status as Off",
|
||||
"brightness": "Brightness to be used if light allows the feature"
|
||||
"brightness": "Brightness to be used if light allows the feature",
|
||||
"history_end_date": "End date of historic",
|
||||
"history_end_time": "End time of historic"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +37,9 @@
|
||||
"restore": "Restore state after simulation",
|
||||
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
|
||||
"unavailable_as_off": "Consider Unavailable status as Off",
|
||||
"brightness": "Brightness to be used if light allows the feature"
|
||||
"brightness": "Brightness to be used if light allows the feature",
|
||||
"history_end_date": "End date of historic",
|
||||
"history_end_time": "End time of historic"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"restore": "Restaurer l'état des entités à la fin de la simulation",
|
||||
"random": "Ajout d'un délai (s) aléatoire pour les changements d'entités",
|
||||
"unavailable_as_off": "Considérer le statut Indisponible comme éteint",
|
||||
"brightness": "Luminosité à utiliser pour les lampes qui le permettent"
|
||||
"brightness": "Luminosité à utiliser pour les lampes qui le permettent",
|
||||
"history_end_date": "Date de fin de l'historique",
|
||||
"history_end_time": "Heure de fin de l'historique"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +37,9 @@
|
||||
"restore": "Restaurer l'état des entités à la fin de la simulation",
|
||||
"random": "Ajout d'un délai (s) aléatoire pour les changements d'entités",
|
||||
"unavailable_as_off": "Considérer le statut Indisponible comme éteint",
|
||||
"brightness": "Luminosité à utiliser pour les lampes qui le permettent"
|
||||
"brightness": "Luminosité à utiliser pour les lampes qui le permettent",
|
||||
"history_end_date": "Date de fin de l'historique",
|
||||
"history_end_time": "Heure de fin de l'historique"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"interval": "Verversingsinterval (in seconden)",
|
||||
"restore": "Herstel toestand na simulatie",
|
||||
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
|
||||
"unavailable_as_off": "Consider Unavailable status as Off"
|
||||
"unavailable_as_off": "Consider Unavailable status as Off",
|
||||
"history_end_date": "Einddatum bronhistorie",
|
||||
"history_end_time": "Eindtijd bronhistorie"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +35,9 @@
|
||||
"interval": "Verversingsinterval (in seconden)",
|
||||
"restore": "Herstel toestand na simulatie",
|
||||
"random": "Add a random time for switching entities. This parameter define the max time in seconds",
|
||||
"unavailable_as_off": "Consider Unavailable status as Off"
|
||||
"unavailable_as_off": "Consider Unavailable status as Off",
|
||||
"history_end_date": "Einddatum bronhistorie",
|
||||
"history_end_time": "Eindtijd bronhistorie"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/timmaurice/sea-temperatures/issues",
|
||||
"requirements": [],
|
||||
"version": "3.3.0"
|
||||
"version": "3.3.1"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
function t(t,e,n,r){var i,a=arguments.length,s=a<3?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var o=t.length-1;o>=0;o--)(i=t[o])&&(s=(a<3?i(s):a>3?i(e,n,s):i(e,n))||s);return a>3&&s&&Object.defineProperty(e,n,s),s}console.groupCollapsed("%c🌊 SEA TEMPERATURES CARD%cv3.3.0","color: orange; font-weight: bold; background: black; padding: 2px 4px; border-radius: 2px 0 0 2px;","color: white; font-weight: bold; background: dimgray; padding: 2px 4px; border-radius: 0 2px 2px 0;"),console.info("A Lovelace card to display sea temperatures."),console.info("Github: https://github.com/timmaurice/sea-temperatures.git"),console.info("Sponsor: https://buymeacoffee.com/timmaurice"),console.groupEnd(),"function"==typeof SuppressedError&&SuppressedError;
|
||||
function t(t,e,n,r){var i,a=arguments.length,s=a<3?e:null===r?r=Object.getOwnPropertyDescriptor(e,n):r;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)s=Reflect.decorate(t,e,n,r);else for(var o=t.length-1;o>=0;o--)(i=t[o])&&(s=(a<3?i(s):a>3?i(e,n,s):i(e,n))||s);return a>3&&s&&Object.defineProperty(e,n,s),s}console.groupCollapsed("%c🌊 SEA TEMPERATURES CARD%cv3.3.1","color: orange; font-weight: bold; background: black; padding: 2px 4px; border-radius: 2px 0 0 2px;","color: white; font-weight: bold; background: dimgray; padding: 2px 4px; border-radius: 0 2px 2px 0;"),console.info("A Lovelace card to display sea temperatures."),console.info("Github: https://github.com/timmaurice/sea-temperatures.git"),console.info("Sponsor: https://buymeacoffee.com/timmaurice"),console.groupEnd(),"function"==typeof SuppressedError&&SuppressedError;
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google LLC
|
||||
@@ -15,7 +15,7 @@ const e=globalThis,n=e.ShadowRoot&&(void 0===e.ShadyCSS||e.ShadyCSS.nativeShadow
|
||||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
const M=globalThis,A=t=>t,S=M.trustedTypes,T=S?S.createPolicy("lit-html",{createHTML:t=>t}):void 0,C="$lit$",N=`lit$${Math.random().toFixed(9).slice(2)}$`,k="?"+N,E=`<${k}>`,D=document,U=()=>D.createComment(""),P=t=>null===t||"object"!=typeof t&&"function"!=typeof t,O=Array.isArray,H="[ \t\n\f\r]",F=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,L=/-->/g,z=/>/g,j=RegExp(`>|${H}(?:([^\\s"'>=/]+)(${H}*=${H}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),R=/'/g,Y=/"/g,q=/^(?:script|style|textarea|title)$/i,I=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),W=I(1),Z=I(2),V=Symbol.for("lit-noChange"),B=Symbol.for("lit-nothing"),G=new WeakMap,X=D.createTreeWalker(D,129);function J(t,e){if(!O(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==T?T.createHTML(e):e}const Q=(t,e)=>{const n=t.length-1,r=[];let i,a=2===e?"<svg>":3===e?"<math>":"",s=F;for(let e=0;e<n;e++){const n=t[e];let o,c,l=-1,u=0;for(;u<n.length&&(s.lastIndex=u,c=s.exec(n),null!==c);)u=s.lastIndex,s===F?"!--"===c[1]?s=L:void 0!==c[1]?s=z:void 0!==c[2]?(q.test(c[2])&&(i=RegExp("</"+c[2],"g")),s=j):void 0!==c[3]&&(s=j):s===j?">"===c[0]?(s=i??F,l=-1):void 0===c[1]?l=-2:(l=s.lastIndex-c[2].length,o=c[1],s=void 0===c[3]?j:'"'===c[3]?Y:R):s===Y||s===R?s=j:s===L||s===z?s=F:(s=j,i=void 0);const h=s===j&&t[e+1].startsWith("/>")?" ":"";a+=s===F?n+E:l>=0?(r.push(o),n.slice(0,l)+C+n.slice(l)+N+h):n+N+(-2===l?e:h)}return[J(t,a+(t[n]||"<?>")+(2===e?"</svg>":3===e?"</math>":"")),r]};class K{constructor({strings:t,_$litType$:e},n){let r;this.parts=[];let i=0,a=0;const s=t.length-1,o=this.parts,[c,l]=Q(t,e);if(this.el=K.createElement(c,n),X.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(r=X.nextNode())&&o.length<s;){if(1===r.nodeType){if(r.hasAttributes())for(const t of r.getAttributeNames())if(t.endsWith(C)){const e=l[a++],n=r.getAttribute(t).split(N),s=/([.?@])?(.*)/.exec(e);o.push({type:1,index:i,name:s[2],strings:n,ctor:"."===s[1]?it:"?"===s[1]?at:"@"===s[1]?st:rt}),r.removeAttribute(t)}else t.startsWith(N)&&(o.push({type:6,index:i}),r.removeAttribute(t));if(q.test(r.tagName)){const t=r.textContent.split(N),e=t.length-1;if(e>0){r.textContent=S?S.emptyScript:"";for(let n=0;n<e;n++)r.append(t[n],U()),X.nextNode(),o.push({type:2,index:++i});r.append(t[e],U())}}}else if(8===r.nodeType)if(r.data===k)o.push({type:2,index:i});else{let t=-1;for(;-1!==(t=r.data.indexOf(N,t+1));)o.push({type:7,index:i}),t+=N.length-1}i++}}static createElement(t,e){const n=D.createElement("template");return n.innerHTML=t,n}}function tt(t,e,n=t,r){if(e===V)return e;let i=void 0!==r?n._$Co?.[r]:n._$Cl;const a=P(e)?void 0:e._$litDirective$;return i?.constructor!==a&&(i?._$AO?.(!1),void 0===a?i=void 0:(i=new a(t),i._$AT(t,n,r)),void 0!==r?(n._$Co??=[])[r]=i:n._$Cl=i),void 0!==i&&(e=tt(t,i._$AS(t,e.values),i,r)),e}class et{constructor(t,e){this._$AV=[],this._$AN=void 0,this._$AD=t,this._$AM=e}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(t){const{el:{content:e},parts:n}=this._$AD,r=(t?.creationScope??D).importNode(e,!0);X.currentNode=r;let i=X.nextNode(),a=0,s=0,o=n[0];for(;void 0!==o;){if(a===o.index){let e;2===o.type?e=new nt(i,i.nextSibling,this,t):1===o.type?e=new o.ctor(i,o.name,o.strings,this,t):6===o.type&&(e=new ot(i,this,t)),this._$AV.push(e),o=n[++s]}a!==o?.index&&(i=X.nextNode(),a++)}return X.currentNode=D,r}p(t){let e=0;for(const n of this._$AV)void 0!==n&&(void 0!==n.strings?(n._$AI(t,n,e),e+=n.strings.length-2):n._$AI(t[e])),e++}}class nt{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(t,e,n,r){this.type=2,this._$AH=B,this._$AN=void 0,this._$AA=t,this._$AB=e,this._$AM=n,this.options=r,this._$Cv=r?.isConnected??!0}get parentNode(){let t=this._$AA.parentNode;const e=this._$AM;return void 0!==e&&11===t?.nodeType&&(t=e.parentNode),t}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(t,e=this){t=tt(this,t,e),P(t)?t===B||null==t||""===t?(this._$AH!==B&&this._$AR(),this._$AH=B):t!==this._$AH&&t!==V&&this._(t):void 0!==t._$litType$?this.$(t):void 0!==t.nodeType?this.T(t):(t=>O(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==B&&P(this._$AH)?this._$AA.nextSibling.data=t:this.T(D.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,r="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=K.createElement(J(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===r)this._$AH.p(e);else{const t=new et(r,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=G.get(t.strings);return void 0===e&&G.set(t.strings,e=new K(t)),e}k(t){O(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,r=0;for(const i of t)r===e.length?e.push(n=new nt(this.O(U()),this.O(U()),this,this.options)):n=e[r],n._$AI(i),r++;r<e.length&&(this._$AR(n&&n._$AB.nextSibling,r),e.Line truncated
|
||||
const M=globalThis,A=t=>t,S=M.trustedTypes,T=S?S.createPolicy("lit-html",{createHTML:t=>t}):void 0,C="$lit$",N=`lit$${Math.random().toFixed(9).slice(2)}$`,k="?"+N,E=`<${k}>`,D=document,U=()=>D.createComment(""),P=t=>null===t||"object"!=typeof t&&"function"!=typeof t,O=Array.isArray,F="[ \t\n\f\r]",H=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,L=/-->/g,z=/>/g,j=RegExp(`>|${F}(?:([^\\s"'>=/]+)(${F}*=${F}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),R=/'/g,Y=/"/g,q=/^(?:script|style|textarea|title)$/i,I=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),W=I(1),Z=I(2),V=Symbol.for("lit-noChange"),B=Symbol.for("lit-nothing"),G=new WeakMap,X=D.createTreeWalker(D,129);function J(t,e){if(!O(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==T?T.createHTML(e):e}const Q=(t,e)=>{const n=t.length-1,r=[];let i,a=2===e?"<svg>":3===e?"<math>":"",s=H;for(let e=0;e<n;e++){const n=t[e];let o,c,l=-1,u=0;for(;u<n.length&&(s.lastIndex=u,c=s.exec(n),null!==c);)u=s.lastIndex,s===H?"!--"===c[1]?s=L:void 0!==c[1]?s=z:void 0!==c[2]?(q.test(c[2])&&(i=RegExp("</"+c[2],"g")),s=j):void 0!==c[3]&&(s=j):s===j?">"===c[0]?(s=i??H,l=-1):void 0===c[1]?l=-2:(l=s.lastIndex-c[2].length,o=c[1],s=void 0===c[3]?j:'"'===c[3]?Y:R):s===Y||s===R?s=j:s===L||s===z?s=H:(s=j,i=void 0);const h=s===j&&t[e+1].startsWith("/>")?" ":"";a+=s===H?n+E:l>=0?(r.push(o),n.slice(0,l)+C+n.slice(l)+N+h):n+N+(-2===l?e:h)}return[J(t,a+(t[n]||"<?>")+(2===e?"</svg>":3===e?"</math>":"")),r]};class K{constructor({strings:t,_$litType$:e},n){let r;this.parts=[];let i=0,a=0;const s=t.length-1,o=this.parts,[c,l]=Q(t,e);if(this.el=K.createElement(c,n),X.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(r=X.nextNode())&&o.length<s;){if(1===r.nodeType){if(r.hasAttributes())for(const t of r.getAttributeNames())if(t.endsWith(C)){const e=l[a++],n=r.getAttribute(t).split(N),s=/([.?@])?(.*)/.exec(e);o.push({type:1,index:i,name:s[2],strings:n,ctor:"."===s[1]?it:"?"===s[1]?at:"@"===s[1]?st:rt}),r.removeAttribute(t)}else t.startsWith(N)&&(o.push({type:6,index:i}),r.removeAttribute(t));if(q.test(r.tagName)){const t=r.textContent.split(N),e=t.length-1;if(e>0){r.textContent=S?S.emptyScript:"";for(let n=0;n<e;n++)r.append(t[n],U()),X.nextNode(),o.push({type:2,index:++i});r.append(t[e],U())}}}else if(8===r.nodeType)if(r.data===k)o.push({type:2,index:i});else{let t=-1;for(;-1!==(t=r.data.indexOf(N,t+1));)o.push({type:7,index:i}),t+=N.length-1}i++}}static createElement(t,e){const n=D.createElement("template");return n.innerHTML=t,n}}function tt(t,e,n=t,r){if(e===V)return e;let i=void 0!==r?n._$Co?.[r]:n._$Cl;const a=P(e)?void 0:e._$litDirective$;return i?.constructor!==a&&(i?._$AO?.(!1),void 0===a?i=void 0:(i=new a(t),i._$AT(t,n,r)),void 0!==r?(n._$Co??=[])[r]=i:n._$Cl=i),void 0!==i&&(e=tt(t,i._$AS(t,e.values),i,r)),e}class et{constructor(t,e){this._$AV=[],this._$AN=void 0,this._$AD=t,this._$AM=e}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(t){const{el:{content:e},parts:n}=this._$AD,r=(t?.creationScope??D).importNode(e,!0);X.currentNode=r;let i=X.nextNode(),a=0,s=0,o=n[0];for(;void 0!==o;){if(a===o.index){let e;2===o.type?e=new nt(i,i.nextSibling,this,t):1===o.type?e=new o.ctor(i,o.name,o.strings,this,t):6===o.type&&(e=new ot(i,this,t)),this._$AV.push(e),o=n[++s]}a!==o?.index&&(i=X.nextNode(),a++)}return X.currentNode=D,r}p(t){let e=0;for(const n of this._$AV)void 0!==n&&(void 0!==n.strings?(n._$AI(t,n,e),e+=n.strings.length-2):n._$AI(t[e])),e++}}class nt{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(t,e,n,r){this.type=2,this._$AH=B,this._$AN=void 0,this._$AA=t,this._$AB=e,this._$AM=n,this.options=r,this._$Cv=r?.isConnected??!0}get parentNode(){let t=this._$AA.parentNode;const e=this._$AM;return void 0!==e&&11===t?.nodeType&&(t=e.parentNode),t}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(t,e=this){t=tt(this,t,e),P(t)?t===B||null==t||""===t?(this._$AH!==B&&this._$AR(),this._$AH=B):t!==this._$AH&&t!==V&&this._(t):void 0!==t._$litType$?this.$(t):void 0!==t.nodeType?this.T(t):(t=>O(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==B&&P(this._$AH)?this._$AA.nextSibling.data=t:this.T(D.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,r="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=K.createElement(J(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===r)this._$AH.p(e);else{const t=new et(r,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=G.get(t.strings);return void 0===e&&G.set(t.strings,e=new K(t)),e}k(t){O(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,r=0;for(const i of t)r===e.length?e.push(n=new nt(this.O(U()),this.O(U()),this,this.options)):n=e[r],n._$AI(i),r++;r<e.length&&(this._$AR(n&&n._$AB.nextSibling,r),e.Line truncated
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google LLC
|
||||
@@ -36,7 +36,7 @@ const dt={attribute:!0,type:String,converter:b,reflect:!1,hasChanged:x},pt=(t=dt
|
||||
* @license
|
||||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/const mt={en:{editor:{groups:{display:"Display"},title:"Title (Optional)",places:"Places",show_trend:"Show trend indicators (vs Yesterday)",show_last_updated:"Show last updated",show_stats:"Show statistics (Yesterday, Last Week, 10-Year Avg)",show_chart:"Show historical chart (30d)",show_country:"Append country to place name",chart_smoothing:"Chart smoothing",chart_smoothing_options:{smooth:"Smooth",linear:"Linear",step:"Step"},sort_by:"Sort places by",sort_by_options:{default:"Default (Manual)",name:"Name (A-Z)",temp_asc:"Temperature (Coldest First)",temp_desc:"Temperature (Warmest First)"},add_place:"Add Place"},card:{yesterday:"Yesterday",last_week:"Last Week",average_avg:"10-Year Avg",averages:"Averages",min:"Min",max:"Max",avg:"Avg",chart_description:"30-day trend",entity_problem:{not_found:"Entity {entity} was not found.",wrong_domain:"{entity} is not a sensor.",not_numeric:"{entity} does not report a temperature.",unavailable:"{entity} is unavailable."}},common:{errors:{no_places:"You need to define at least one place.",duplicate_place:"This place is already in your list."}}},de:{editor:{groups:{display:"Anzeige"},title:"Titel (Optional)",places:"Orte",show_trend:"Zeige Trend-Indikatoren (vs Gestern)",show_last_updated:"Zuletzt aktualisiert anzeigen",show_stats:"Statistiken anzeigen (Gestern, Letzte Woche, 10-Jahres-Ø)",show_chart:"Historisches Diagramm anzeigen (30t)",show_country:"Land an den Ortsnamen anhängen",chart_smoothing:"Kurvenglättung",chart_smoothing_options:{smooth:"Weich",linear:"Linear",step:"Stufen"},sort_by:"Orte sortieren nach",sort_by_options:{default:"Standard (Manuell)",name:"Name (A-Z)",temp_asc:"Temperatur (Kälteste zuerst)",temp_desc:"Temperatur (Wärmste zuerst)"},add_place:"Ort hinzufügen"},card:{yesterday:"Gestern",last_week:"Letzte Woche",average_avg:"10-Jahres-Ø",averages:"Durchschnitte",min:"Min",max:"Max",avg:"Ø",chart_description:"30-Tage-Trend",entity_problem:{not_found:"Entität {entity} wurde nicht gefunden.",wrong_domain:"{entity} ist kein Sensor.",not_numeric:"{entity} liefert keine Temperatur.",unavailable:"{entity} ist nicht verfügbar."}},common:{errors:{no_places:"Du musst mindestens einen Ort definieren.",duplicate_place:"Dieser Ort ist bereits in deiner Liste."}}},fr:{editor:{groups:{display:"Affichage"},title:"Titre (Optionnel)",places:"Lieux",show_trend:"Afficher les indicateurs de tendance (vs Hier)",show_last_updated:"Afficher la dernière mise à jour",show_stats:"Afficher les statistiques (Hier, La semaine dernière, Moy. 10 ans)",show_chart:"Afficher le graphique historique (30j)",show_country:"Ajouter le pays au nom du lieu",chart_smoothing:"Lissage du graphique",chart_smoothing_options:{smooth:"Lisse",linear:"Linéaire",step:"En escalier"},sort_by:"Trier les lieux par",sort_by_options:{default:"Défaut (Manuel)",name:"Nom (A-Z)",temp_asc:"Température (Le plus froid d'abord)",temp_desc:"Température (Le plus chaud d'abord)"},add_place:"Ajouter un lieu"},card:{yesterday:"Hier",last_week:"La semaine dernière",average_avg:"Moy. 10 ans",averages:"Moyennes",min:"Min",max:"Max",avg:"Moy",chart_description:"Tendance sur 30 jours",entity_problem:{not_found:"L'entité {entity} est introuvable.",wrong_domain:"{entity} n'est pas un capteur.",not_numeric:"{entity} ne fournit pas de température.",unavailable:"{entity} est indisponible."}},common:{errors:{no_places:"Vous devez définir au moins un lieu.",duplicate_place:"Ce lieu est déjà dans votre liste."}}},es:{editor:{groups:{display:"Pantalla"},title:"Título (Opcional)",places:"Lugares",show_trend:"Mostrar indicadores de tendencia (vs Ayer)",show_last_updated:"Mostrar última actualización",show_stats:"Mostrar estadísticas (Ayer, Semana pasada, Media 10 años)",show_chart:"Mostrar gráfico histórico (30d)",show_country:"Añadir el país al nombre del lugar",chart_smoothing:"Suavizado del gráfico",chart_smoothing_options:{smooth:"Suave",linear:"Lineal",step:"Escalonado"},sort_by:"Ordenar lugares por",sort_by_options:{default:"Por defecto (Manual)",name:"Nombre (A-Z)",temp_asc:"Temperatura (Más frío primero)",temp_desc:"Temperatura (Más cálido primero)"},add_place:"Añadir Lugar"},card:{yesterday:"Ayer",last_week:"Semana Pasada",average_avg:"Media 10 años",averages:"Medias",min:"Mín",max:"Máx",avg:"Med",chart_description:"Tendencia de 30 días",entity_problem:{not_found:"No se encontró la entidad {entity}.",wrong_domain:"{entity} no es un sensor.",not_numeric:"{entity} no proporciona una temperatura.",unavailable:"{entity} no está disponible."}},common:{errors:{no_places:"Debes definir al menos un lugar.",duplicate_place:"Este lugar ya está en tu lista."}}},it:{editor:{groups:{display:"Visualizzazione"},title:"Titolo (Opzionale)",places:"Luoghi",show_trend:"Mostra indicatori di tendenza (vs Ieri)",show_last_updated:"Mostra ultimo aggiornamento",show_stats:"Mostra statistiche (Ieri, Settimana scorsa, Media 10 anni)",show_chart:"Mostra grafico storico (30g)",show_country:"Aggiungi ilLine truncated
|
||||
*/const mt={en:{editor:{groups:{display:"Display"},title:"Title (Optional)",places:"Places",show_trend:"Show trend indicators (vs Yesterday)",show_last_updated:"Show last updated",show_stats:"Show statistics (Yesterday, Last Week, 10-Year Avg)",show_chart:"Show historical chart (30d)",show_country:"Append country to place name",chart_smoothing:"Chart smoothing",chart_smoothing_options:{smooth:"Smooth",linear:"Linear",step:"Step"},sort_by:"Sort places by",sort_by_options:{default:"Default (Manual)",name:"Name (A-Z)",temp_asc:"Temperature (Coldest First)",temp_desc:"Temperature (Warmest First)"},add_place:"Add Place"},card:{yesterday:"Yesterday",last_week:"Last Week",average_avg:"10-Year Avg",averages:"Averages",min:"Min",max:"Max",avg:"Avg",chart_description:"30-day trend",entity_problem:{not_found:"Entity {entity} was not found.",wrong_domain:"{entity} is not a sensor.",not_numeric:"{entity} does not report a temperature.",unavailable:"{entity} is unavailable."}},common:{errors:{no_places:"You need to define at least one place.",duplicate_place:"This place is already in your list."}}},de:{editor:{groups:{display:"Anzeige"},title:"Titel (Optional)",places:"Orte",show_trend:"Zeige Trend-Indikatoren (vs Gestern)",show_last_updated:"Zuletzt aktualisiert anzeigen",show_stats:"Statistiken anzeigen (Gestern, Letzte Woche, 10-Jahres-Ø)",show_chart:"Historisches Diagramm anzeigen (30t)",show_country:"Land an den Ortsnamen anhängen",chart_smoothing:"Kurvenglättung",chart_smoothing_options:{smooth:"Weich",linear:"Linear",step:"Stufen"},sort_by:"Orte sortieren nach",sort_by_options:{default:"Standard (Manuell)",name:"Name (A-Z)",temp_asc:"Temperatur (Kälteste zuerst)",temp_desc:"Temperatur (Wärmste zuerst)"},add_place:"Ort hinzufügen"},card:{yesterday:"Gestern",last_week:"Letzte Woche",average_avg:"10-Jahres-Ø",averages:"Durchschnitte",min:"Min",max:"Max",avg:"Ø",chart_description:"30-Tage-Trend",entity_problem:{not_found:"Entität {entity} wurde nicht gefunden.",wrong_domain:"{entity} ist kein Sensor.",not_numeric:"{entity} liefert keine Temperatur.",unavailable:"{entity} ist nicht verfügbar."}},common:{errors:{no_places:"Du musst mindestens einen Ort definieren.",duplicate_place:"Dieser Ort ist bereits in deiner Liste."}}},fr:{editor:{groups:{display:"Affichage"},title:"Titre (Optionnel)",places:"Lieux",show_trend:"Afficher les indicateurs de tendance (vs Hier)",show_last_updated:"Afficher la dernière mise à jour",show_stats:"Afficher les statistiques (Hier, La semaine dernière, Moy. 10 ans)",show_chart:"Afficher le graphique historique (30j)",show_country:"Ajouter le pays au nom du lieu",chart_smoothing:"Lissage du graphique",chart_smoothing_options:{smooth:"Lisse",linear:"Linéaire",step:"En escalier"},sort_by:"Trier les lieux par",sort_by_options:{default:"Défaut (Manuel)",name:"Nom (A-Z)",temp_asc:"Température (Le plus froid d'abord)",temp_desc:"Température (Le plus chaud d'abord)"},add_place:"Ajouter un lieu"},card:{yesterday:"Hier",last_week:"La semaine dernière",average_avg:"Moy. 10 ans",averages:"Moyennes",min:"Min",max:"Max",avg:"Moy",chart_description:"Tendance sur 30 jours",entity_problem:{not_found:"L'entité {entity} est introuvable.",wrong_domain:"{entity} n'est pas un capteur.",not_numeric:"{entity} ne fournit pas de température.",unavailable:"{entity} est indisponible."}},common:{errors:{no_places:"Vous devez définir au moins un lieu.",duplicate_place:"Ce lieu est déjà dans votre liste."}}},es:{editor:{groups:{display:"Pantalla"},title:"Título (Opcional)",places:"Lugares",show_trend:"Mostrar indicadores de tendencia (vs Ayer)",show_last_updated:"Mostrar última actualización",show_stats:"Mostrar estadísticas (Ayer, Semana pasada, Media 10 años)",show_chart:"Mostrar gráfico histórico (30d)",show_country:"Añadir el país al nombre del lugar",chart_smoothing:"Suavizado del gráfico",chart_smoothing_options:{smooth:"Suave",linear:"Lineal",step:"Escalonado"},sort_by:"Ordenar lugares por",sort_by_options:{default:"Por defecto (Manual)",name:"Nombre (A-Z)",temp_asc:"Temperatura (Más frío primero)",temp_desc:"Temperatura (Más cálido primero)"},add_place:"Añadir Lugar"},card:{yesterday:"Ayer",last_week:"Semana Pasada",average_avg:"Media 10 años",averages:"Medias",min:"Mín",max:"Máx",avg:"Med",chart_description:"Tendencia de 30 días",entity_problem:{not_found:"No se encontró la entidad {entity}.",wrong_domain:"{entity} no es un sensor.",not_numeric:"{entity} no proporciona una temperatura.",unavailable:"{entity} no está disponible."}},common:{errors:{no_places:"Debes definir al menos un lugar.",duplicate_place:"Este lugar ya está en tu lista."}}},it:{editor:{groups:{display:"Visualizzazione"},title:"Titolo (Opzionale)",places:"Luoghi",show_trend:"Mostra indicatori di tendenza (vs Ieri)",show_last_updated:"Mostra ultimo aggiornamento",show_stats:"Mostra statistiche (Ieri, Settimana scorsa, Media 10 anni)",show_chart:"Mostra grafico storico (30g)",show_country:"Aggiungi ilLine truncated
|
||||
${t?"↑":"↓"} ${t?"+":"-"}${e}${n}
|
||||
</div>`}const s=wt(0,this.hass,{minimumFractionDigits:1,maximumFractionDigits:1});return W`<div class="stat-delta neu current-trend">→ ${s}${n}</div>`}render(){if(!this._config||!this.hass)return W``;const t=this._getPlacesData(this.hass,this._config),e=this._chartWidth<360;return W`
|
||||
<ha-card .header=${this._config.title} tabindex="0" class="${e?"narrow":""}">
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user