203 files

This commit is contained in:
Home Assistant Version Control
2026-09-19 17:31:12 +00:00
parent 279159581d
commit 99467464e4
203 changed files with 2035 additions and 241 deletions
+1 -1
View File
@@ -1 +1 @@
2026.9.2
2026.9.3
+1 -1
View File
@@ -1 +1 @@
{"pid": 71, "version": 1, "ha_version": "2026.9.2", "start_ts": 1789746086.7330012}
{"pid": 71, "version": 1, "ha_version": "2026.9.3", "start_ts": 1789839023.3776524}
+32 -32
View File
@@ -81,117 +81,117 @@
},
{
"id": "88b90f2b1d6b45f0b44bee786ee1a525",
"url": "/taskmate/taskmate-attr-resolver.js?v=5.6.1",
"url": "/taskmate/taskmate-attr-resolver.js?v=5.7.0",
"type": "module"
},
{
"id": "0796c92931dd458e81dc9c2677248274",
"url": "/taskmate/taskmate-localize.js?v=5.6.1",
"url": "/taskmate/taskmate-localize.js?v=5.7.0",
"type": "module"
},
{
"id": "d2e281920ac7409583eafff1f1db69bd",
"url": "/taskmate/taskmate-design.js?v=5.6.1",
"url": "/taskmate/taskmate-design.js?v=5.7.0",
"type": "module"
},
{
"id": "9eae9632f0734480be5edd784b030673",
"url": "/taskmate/taskmate-badges-card.js?v=5.6.1",
"url": "/taskmate/taskmate-badges-card.js?v=5.7.0",
"type": "module"
},
{
"id": "5df49cd8a4eb420497b5df22cb31362c",
"url": "/taskmate/taskmate-child-card.js?v=5.6.1",
"url": "/taskmate/taskmate-child-card.js?v=5.7.0",
"type": "module"
},
{
"id": "b44c87d0d74f468cb96c5eabc340f9ee",
"url": "/taskmate/taskmate-rewards-card.js?v=5.6.1",
"url": "/taskmate/taskmate-rewards-card.js?v=5.7.0",
"type": "module"
},
{
"id": "787a96f9c30548708165a48457c99feb",
"url": "/taskmate/taskmate-approvals-card.js?v=5.6.1",
"url": "/taskmate/taskmate-approvals-card.js?v=5.7.0",
"type": "module"
},
{
"id": "84031597de424ad68e7c2bc66d06b72e",
"url": "/taskmate/taskmate-points-card.js?v=5.6.1",
"url": "/taskmate/taskmate-points-card.js?v=5.7.0",
"type": "module"
},
{
"id": "02613225844746b699bfbfac9642eca2",
"url": "/taskmate/taskmate-reorder-card.js?v=5.6.1",
"url": "/taskmate/taskmate-reorder-card.js?v=5.7.0",
"type": "module"
},
{
"id": "7a06a07e17ba4b31b712bc91b77360b4",
"url": "/taskmate/taskmate-overview-card.js?v=5.6.1",
"url": "/taskmate/taskmate-overview-card.js?v=5.7.0",
"type": "module"
},
{
"id": "08b42b53b33b4fa88db932455cf8d5aa",
"url": "/taskmate/taskmate-activity-card.js?v=5.6.1",
"url": "/taskmate/taskmate-activity-card.js?v=5.7.0",
"type": "module"
},
{
"id": "15907eff51784acdba88ff694b574509",
"url": "/taskmate/taskmate-streak-card.js?v=5.6.1",
"url": "/taskmate/taskmate-streak-card.js?v=5.7.0",
"type": "module"
},
{
"id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39",
"url": "/taskmate/taskmate-weekly-card.js?v=5.6.1",
"url": "/taskmate/taskmate-weekly-card.js?v=5.7.0",
"type": "module"
},
{
"id": "289ce5943f5a4972a593697db4c96ece",
"url": "/taskmate/taskmate-graph-card.js?v=5.6.1",
"url": "/taskmate/taskmate-graph-card.js?v=5.7.0",
"type": "module"
},
{
"id": "d2904acee4c74a398dcc7b996c23e7e7",
"url": "/taskmate/taskmate-reward-progress-card.js?v=5.6.1",
"url": "/taskmate/taskmate-reward-progress-card.js?v=5.7.0",
"type": "module"
},
{
"id": "46d378736fd54a26be253f2dfb27202e",
"url": "/taskmate/taskmate-leaderboard-card.js?v=5.6.1",
"url": "/taskmate/taskmate-leaderboard-card.js?v=5.7.0",
"type": "module"
},
{
"id": "db11ae36457842fa826c7991e1d9c6f4",
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.6.1",
"url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.7.0",
"type": "module"
},
{
"id": "ce85f301b59a412e9abfe5ffd51cb9a2",
"url": "/taskmate/taskmate-penalties-card.js?v=5.6.1",
"url": "/taskmate/taskmate-penalties-card.js?v=5.7.0",
"type": "module"
},
{
"id": "1959e1629de2422daa8adaf87069c288",
"url": "/taskmate/taskmate-bonuses-card.js?v=5.6.1",
"url": "/taskmate/taskmate-bonuses-card.js?v=5.7.0",
"type": "module"
},
{
"id": "2548524b269c4959bc7e0e967df943af",
"url": "/taskmate/taskmate-points-display-card.js?v=5.6.1",
"url": "/taskmate/taskmate-points-display-card.js?v=5.7.0",
"type": "module"
},
{
"id": "36ada49368db4187a283fbbbef45454f",
"url": "/taskmate/taskmate-calendar-card.js?v=5.6.1",
"url": "/taskmate/taskmate-calendar-card.js?v=5.7.0",
"type": "module"
},
{
"id": "67c32687acd2497b90030ef141f4b6db",
"url": "/taskmate/taskmate-photo-gallery-card.js?v=5.6.1",
"url": "/taskmate/taskmate-photo-gallery-card.js?v=5.7.0",
"type": "module"
},
{
"id": "6f0c8348a91f4b74a9b846ab3f62621f",
"url": "/taskmate/taskmate-family-goal-card.js?v=5.6.1",
"url": "/taskmate/taskmate-family-goal-card.js?v=5.7.0",
"type": "module"
},
{
@@ -206,7 +206,7 @@
},
{
"id": "d3c3e144a91247ce8ac524814737d7f8",
"url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164663",
"url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164670",
"type": "module"
},
{
@@ -276,7 +276,7 @@
},
{
"id": "73dbd2de03de4530b451a55bfe6c46cf",
"url": "/taskmate/taskmate-routine-card.js?v=5.6.1",
"url": "/taskmate/taskmate-routine-card.js?v=5.7.0",
"type": "module"
},
{
@@ -304,11 +304,6 @@
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
"type": "module"
},
{
"id": "6f76aeefdbb14d87a0da4c28957efb47",
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.9.1",
"type": "module"
},
{
"id": "aafb0bb764874be384629a0ca0a18c53",
"url": "/my_music_library/my-music-library-card.js?v=3.14.0",
@@ -316,11 +311,16 @@
},
{
"id": "1a4939ce1d2b47f89502176d3c5ac6e7",
"url": "/taskmate/taskmate-sounds.js?v=5.6.1",
"url": "/taskmate/taskmate-sounds.js?v=5.7.0",
"type": "module"
},
{
"id": "6669c4f2fe44455b96d5e652e39bea01",
"id": "2dfca9e286bb4c5fb92ac3a46332fb3b",
"url": "/album_slideshow_static/album-slideshow-card.js?v=1.10.0",
"type": "module"
},
{
"id": "2e1a9bdf3bfd46859371e8f3389286a9",
"url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1",
"type": "module"
}
+59 -4
View File
@@ -52,6 +52,29 @@ _SKIP_SEARCH_LIMIT = 30
_MAX_RENDER_ATTEMPTS = 10
# Fixed floor (in items) for the pairing cooldown exclusion zone, applied on
# each side of the current index in addition to the percentage-based radius.
# Keeps tiny albums from pairing consecutive/near-consecutive shots even when
# a small percentage rounds down to zero.
_PAIR_GAP_FLOOR = 2
def _pair_exclusion_radius(n: int, min_gap_percent: float) -> int:
"""Items to exclude on each side of the current index for pairing.
Combines ``_PAIR_GAP_FLOOR`` with a percentage of the album size so tiny
albums still get some separation while large albums scale
proportionally. A ``min_gap_percent`` of 0 disables the cooldown
entirely. The result is capped well below ``n / 2`` so eligible
candidates always remain, even for small albums (a ring buffer of
``n`` items only has ``n - 1`` non-self candidates to begin with).
"""
if n <= 3 or min_gap_percent <= 0:
return 0
radius = max(_PAIR_GAP_FLOOR, round(n * (min_gap_percent / 100.0)))
max_radius = (n - 3) // 2
return max(0, min(radius, max_radius))
@dataclass(frozen=True, slots=True)
class _NavigationCursor:
@@ -315,6 +338,7 @@ class AlbumSlideshowCamera(Camera):
"aspect_ratio": self.store.aspect_ratio,
"pair_divider_px": int(self.store.pair_divider_px),
"pair_divider_color": self.store.pair_divider_color,
"pair_min_gap_percent": int(self.store.pair_min_gap_percent),
"frame_id": self._frame_id,
"navigation_buffer_size": self._buffer_depth,
"previous_frames_cached": len(self._previous_frames),
@@ -1129,6 +1153,27 @@ class AlbumSlideshowCamera(Camera):
) -> tuple[Image.Image, MediaItem] | None:
"""Find an image with the opposite orientation of the canvas.
By default (``pair_min_gap_percent`` = 0) this is the original
search: walk forward from the very next item, nearest candidate
wins, fully deterministic. Setting ``pair_min_gap_percent`` above 0
opts into a cooldown search instead - candidates within that
percentage of the current index (wrapping on both sides, since the
album is a ring buffer) are excluded so the partner comes from a
meaningfully different part of the collection, and the remaining
candidates are shuffled before scanning so the same nearby partner
isn't picked deterministically every time. Small albums where the
cooldown would leave no candidates fall back to the deterministic
search (see ``_pair_exclusion_radius``); no eligible partner at all
(orientation, recent-repeat, or download failures) is a normal
outcome and simply returns ``None``, which the caller renders as a
single unpaired image.
This is a pure function of ``self._index``, ``self._recent_urls``
and ``self._rng``'s state, which is exactly what the navigation
buffer's cursor snapshots capture - so replaying a cached cursor
(Previous/Next) always reproduces the same paired frame; only
rendering a slide for the first time consumes new randomness.
Uses metadata wherever possible - only candidates without width/height
metadata are downloaded and decoded for their orientation. The returned
PIL image is the caller's to close. The matching ``MediaItem`` is
@@ -1137,12 +1182,22 @@ class AlbumSlideshowCamera(Camera):
if not items:
return None
n = len(items)
radius = _pair_exclusion_radius(n, self.store.pair_min_gap_percent)
if radius > 0:
excluded = {(self._index + d) % n for d in range(-radius, radius + 1)}
candidates = [idx for idx in range(n) if idx not in excluded]
self._rng.shuffle(candidates)
else:
candidates = (
(self._index + offset) % n
for offset in range(1, min(n, limit + 1))
)
tries = 0
offset = 1
while tries < limit and offset < n:
idx = (self._index + offset) % n
for idx in candidates:
if tries >= limit:
break
it = items[idx]
offset += 1
tries += 1
if it.url in self._recent_urls:
@@ -1254,7 +1254,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
}
)
return self.async_show_form(
step_id="ente", data_schema=schema, errors=errors
step_id="ente",
data_schema=schema,
errors=errors,
description_placeholders={
"example_url": "https://albums.ente.io/?t=TOKEN#KEY"
},
)
@@ -290,6 +290,13 @@ DEFAULT_ORDER_MODE = ORDER_RANDOM
DEFAULT_ASPECT_RATIO = "16:9"
DEFAULT_PAIR_DIVIDER_PX = 8
DEFAULT_PAIR_DIVIDER_COLOR = "#FFFFFF"
# Minimum separation (as a percentage of album size) enforced between the
# current slide's index and a candidate orientation-mismatch pairing
# partner. Helps avoid nearby photos; does not detect photo sessions. Opt-in: 0
# (the default) keeps the original deterministic "nearest candidate first"
# search; a positive value switches to a shuffled search that excludes the
# cooldown zone around the current index.
DEFAULT_PAIR_MIN_GAP_PERCENT = 0
DEFAULT_RECURSIVE = True
# Per-album download cache. Multiple albums add up: 4 × 150 MB = 600 MB
# of just-in-case downloaded JPEGs. 75 MB caches roughly 10-20 photos
+140 -38
View File
@@ -9,8 +9,8 @@ Legacy "shared streams" backend (``www.icloud.com/sharedalbum/#TOKEN``):
- ``POST {base}/webstream`` ``{"streamCtag": null}`` -> ``{streamName, photos:
[{photoGuid, derivatives:{<height>:{checksum,width,height,fileSize}},
dateCreated, caption, width, height}]}``. May first answer with a
``330`` redirect carrying an ``X-Apple-MMe-Host`` header pointing at the
correct partition host; retry there.
``330`` redirect carrying ``X-Apple-MMe-Host`` in the JSON body (or a
response header) pointing at the correct partition host; retry there.
- ``POST {base}/webasseturls`` ``{"photoGuids": [...]}`` -> ``{items:
{<checksum>: {url_location, url_path, url_expiry}}}``. Build the image URL
as ``https://{url_location}{url_path}``; it is a signed CDN link that
@@ -31,14 +31,35 @@ Apple strips GPS from shared-album web data, so there is no location.
from __future__ import annotations
import base64
from asyncio import sleep as _sleep
from datetime import datetime, timezone
import json
import logging
import re
from time import monotonic
from typing import Any
from urllib.parse import quote, urlencode
from aiohttp import (
ClientConnectionError,
ClientError,
ClientPayloadError,
ClientSSLError,
ClientTimeout,
)
import async_timeout
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
# CloudKit retains its existing timeout. Legacy listing/URL resolution can be
# slow for large albums; keep connection, idle-read and total time bounded.
_TIMEOUT = 30
_LEGACY_TIMEOUT = ClientTimeout(total=90, connect=15, sock_read=60)
_REQUEST_ATTEMPTS = 2
_RETRY_DELAY_SECONDS = 1.0
_RETRYABLE_STATUSES = {408, 500, 502, 503, 504}
_PARTITION_HOST_RE = re.compile(r"p[0-9]+-sharedstreams\.icloud\.com")
_MAX_ASSETS = 20_000
# webasseturls request batch size. A single call handled 40+ guids fine in
# testing; chunking keeps request bodies bounded for very large albums.
@@ -277,6 +298,10 @@ def parse_photo_meta(photo: dict[str, Any]) -> dict[str, Any]:
return out
class IcloudRequestError(RuntimeError):
"""A request failure safe to surface without share URLs or response data."""
class IcloudClient:
"""Thin async wrapper over the iCloud shared-streams web API."""
@@ -291,62 +316,139 @@ class IcloudClient:
def base_url(self) -> str:
return base_url(self.token, self._host)
async def _post(self, path: str, body: dict[str, Any]) -> tuple[int, dict[str, str], bytes]:
async def _post(
self, path: str, body: dict[str, Any], *, operation: str
) -> dict[str, Any]:
"""Read JSON with a bounded retry and at most one trusted redirect.
All calls are read-only despite using POST. Retry only the failing
request, not previously completed URL batches. Never include a share
token, response body, or raw transport exception in diagnostics.
"""
session = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with session.post(
self.base_url + path, json=body, headers=_API_HEADERS
) as resp:
return resp.status, dict(resp.headers), await resp.read()
started = monotonic()
attempt = 1
redirects = 0
while True:
request_started = monotonic()
host = self._host or partition_host(self.token)
phase = "waiting for response headers"
retryable = False
try:
async with session.post(
self.base_url + path,
json=body,
headers=_API_HEADERS,
timeout=_LEGACY_TIMEOUT,
allow_redirects=False,
) as resp:
status = resp.status
if status not in (200, 330):
detail = f"HTTP {status}"
retryable = status in _RETRYABLE_STATUSES
else:
# A usable 330 header needs no body. Do not let a
# stalled redirect body prevent reaching the new host.
target = resp.headers.get("X-Apple-MMe-Host") if status == 330 else None
payload = None
if not target:
phase = "reading response body"
raw = await resp.read()
phase = "decoding JSON"
try:
payload = json.loads(raw)
except (ValueError, UnicodeDecodeError):
payload = None
if status == 330:
phase = "handling partition redirect"
if not target and isinstance(payload, dict):
target = payload.get("X-Apple-MMe-Host")
target = target.strip().lower() if isinstance(target, str) else ""
if not _PARTITION_HOST_RE.fullmatch(target):
detail = "HTTP 330 with invalid or missing partition host"
elif redirects or target == host:
detail = "HTTP 330 partition redirect loop"
else:
# The share token must never be sent to a
# caller-supplied or lookalike redirect host.
self._host = target
redirects += 1
_LOGGER.debug(
"iCloud %s: partition redirect %s -> %s",
operation, host, target,
)
continue
elif isinstance(payload, dict):
_LOGGER.debug(
"iCloud %s succeeded (host=%s; endpoint=%s; "
"attempt=%d/%d; elapsed=%.1fs; total_elapsed=%.1fs)",
operation, host, path, attempt, _REQUEST_ATTEMPTS,
monotonic() - request_started, monotonic() - started,
)
return payload
else:
detail = "Invalid JSON response (expected an object)"
except TimeoutError:
detail = (
"TimeoutError "
f"(connect={_LEGACY_TIMEOUT.connect}s, "
f"read={_LEGACY_TIMEOUT.sock_read}s, "
f"total={_LEGACY_TIMEOUT.total}s)"
)
retryable = True
except ClientSSLError as err:
detail = type(err).__name__
except (ClientConnectionError, ClientPayloadError) as err:
detail = type(err).__name__
retryable = True
except ClientError as err:
detail = type(err).__name__
error = IcloudRequestError(
f"iCloud {operation} failed while {phase} "
f"(host={host}; endpoint={path}; attempt={attempt}/{_REQUEST_ATTEMPTS}; "
f"elapsed={monotonic() - request_started:.1f}s; "
f"total_elapsed={monotonic() - started:.1f}s): {detail}"
)
if not retryable or attempt >= _REQUEST_ATTEMPTS:
# Raw aiohttp errors can contain the full album URL. Suppress
# that context even when HA logs a traceback of this error.
raise error from None
_LOGGER.warning("%s; retrying in %.1fs", error, _RETRY_DELAY_SECONDS)
await _sleep(_RETRY_DELAY_SECONDS)
attempt += 1
async def async_get_photos(self) -> list[dict[str, Any]]:
"""Fetch the album's photo list, following a partition redirect once."""
import json as _json
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
redirect_host = headers.get("X-Apple-MMe-Host")
if redirect_host and redirect_host != self._host:
# Our partition guess was wrong; Apple told us the right host.
self._host = redirect_host
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
if status != 200:
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
payload = _json.loads(raw)
payload = await self._post(
"/webstream", {"streamCtag": None}, operation="photo list"
)
return parse_webstream(payload)[:_MAX_ASSETS]
async def async_get_asset_urls(
self, guids: list[str]
) -> dict[str, dict[str, Any]]:
"""Resolve signed asset URLs for photo guids, keyed by checksum."""
import json as _json
out: dict[str, dict[str, Any]] = {}
batch_count = (len(guids) + _URL_BATCH - 1) // _URL_BATCH
for start in range(0, len(guids), _URL_BATCH):
chunk = guids[start : start + _URL_BATCH]
status, _headers, raw = await self._post(
"/webasseturls", {"photoGuids": chunk}
batch_number = start // _URL_BATCH + 1
payload = await self._post(
"/webasseturls", {"photoGuids": chunk},
operation=f"image URLs batch {batch_number}/{batch_count} ({len(chunk)} photos)",
)
if status != 200:
raise RuntimeError(f"iCloud webasseturls failed: HTTP {status}")
payload = _json.loads(raw)
items = payload.get("items") if isinstance(payload, dict) else None
items = payload.get("items")
if isinstance(items, dict):
out.update(items)
return out
async def async_validate(self) -> str | None:
"""Return the album name if the token works, else raise."""
import json as _json
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
redirect_host = headers.get("X-Apple-MMe-Host")
if redirect_host and redirect_host != self._host:
self._host = redirect_host
status, headers, raw = await self._post("/webstream", {"streamCtag": None})
if status != 200:
raise RuntimeError(f"iCloud webstream failed: HTTP {status}")
payload = _json.loads(raw)
return payload.get("streamName") if isinstance(payload, dict) else None
payload = await self._post(
"/webstream", {"streamCtag": None}, operation="link validation"
)
return payload.get("streamName")
# --- CloudKit backend helpers ---------------------------------------------
@@ -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.9.1"
"version": "1.10.0"
}
@@ -20,6 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
SlideIntervalNumber(entry, store),
RefreshHoursNumber(entry, store, coordinator),
PairDividerWidthNumber(entry, store),
PairMinGapPercentNumber(entry, store),
NavigationBufferSizeNumber(entry, store),
ImageCacheMbNumber(entry, store),
]
@@ -144,6 +145,37 @@ class PairDividerWidthNumber(_BaseNumber):
return
class PairMinGapPercentNumber(_BaseNumber):
_attr_icon = "mdi:arrow-expand-horizontal"
_attr_native_min_value = 0
_attr_native_max_value = 50
_attr_native_step = 1
_attr_native_unit_of_measurement = "%"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_pair_min_gap_percent"
self._attr_name = "Pair minimum gap (% of album)"
@property
def native_value(self):
return int(self.store.pair_min_gap_percent)
async def async_set_native_value(self, value: float) -> None:
self.store.pair_min_gap_percent = min(50, max(0, int(value)))
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old = await self.async_get_last_state()
if old and old.state not in (None, "unknown", "unavailable"):
try:
self.store.pair_min_gap_percent = min(50, max(0, int(float(old.state))))
self.store.notify()
except Exception:
return
class NavigationBufferSizeNumber(_BaseNumber):
_attr_icon = "mdi:page-previous-outline"
_attr_native_min_value = 0
@@ -12,6 +12,7 @@ from .const import (
DEFAULT_ASPECT_RATIO,
DEFAULT_PAIR_DIVIDER_PX,
DEFAULT_PAIR_DIVIDER_COLOR,
DEFAULT_PAIR_MIN_GAP_PERCENT,
DEFAULT_IMAGE_CACHE_MB,
DEFAULT_NAVIGATION_BUFFER_SIZE,
DEFAULT_MAX_RESOLUTION,
@@ -33,6 +34,7 @@ class SlideshowStore:
aspect_ratio: str = DEFAULT_ASPECT_RATIO
pair_divider_px: int = DEFAULT_PAIR_DIVIDER_PX
pair_divider_color: str = DEFAULT_PAIR_DIVIDER_COLOR
pair_min_gap_percent: int = DEFAULT_PAIR_MIN_GAP_PERCENT
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
navigation_buffer_size: int = DEFAULT_NAVIGATION_BUFFER_SIZE
max_resolution: str = DEFAULT_MAX_RESOLUTION
@@ -132,7 +132,7 @@
},
"ente": {
"title": "Ente Photos public album",
"description": "Paste a public Ente album link, for example https://albums.ente.io/?t=TOKEN#KEY. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
"description": "Paste a public Ente album link, for example {example_url}. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
"data": {
"album_name": "Album name",
"ente_url": "Album share link",
@@ -172,7 +172,7 @@
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path.",
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
"invalid_ente_url": "That does not look like an Ente public album link. It should look like https://albums.ente.io/?t=TOKEN#KEY, including the part after the #."
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #."
,"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
}
},
@@ -132,7 +132,7 @@
},
"ente": {
"title": "Ente Photos public album",
"description": "Paste a public Ente album link, for example https://albums.ente.io/?t=TOKEN#KEY. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
"description": "Paste a public Ente album link, for example {example_url}. Copy the whole link including the part after the # - that fragment is the decryption key and the album cannot be read without it. No Ente account or password is needed. Leave the API endpoint blank unless you self-host Ente.",
"data": {
"album_name": "Album name",
"ente_url": "Album share link",
@@ -172,7 +172,7 @@
"nextcloud_cannot_connect": "Could not connect to Nextcloud. Check the URL, username, app password and folder path.",
"invalid_nextcloud_url": "That does not look like a Nextcloud Photos public album link.",
"nextcloud_public_cannot_connect": "Could not connect to that Nextcloud album. Check the link is still valid.",
"invalid_ente_url": "That does not look like an Ente public album link. It should look like https://albums.ente.io/?t=TOKEN#KEY, including the part after the #."
"invalid_ente_url": "That does not look like an Ente public album link. It should look like {example_url}, including the part after the #."
,"ente_cannot_connect": "Could not open that Ente album. The link may be expired, revoked, or password-protected."
}
},
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.9.1";
const VERSION = "1.10.0";
const ANIMATED_TRANSITIONS = [
"fade",
@@ -969,6 +969,7 @@ const LIVE_FIELDS = [
"slide_interval",
"pair_divider_px",
"pair_divider_color",
"pair_min_gap_percent",
];
const LIVE_SUFFIX = {
@@ -980,6 +981,7 @@ const LIVE_SUFFIX = {
slide_interval: "_interval",
pair_divider_px: "_pair_divider_px",
pair_divider_color: "_pair_divider_color",
pair_min_gap_percent: "_pair_min_gap_percent",
previous_button: "_previous_button",
next_button: "_next_button",
refresh_button: "_refresh_button",
@@ -994,6 +996,7 @@ const LIVE_LABELS = {
live_slide_interval: "Slide interval (seconds)",
live_pair_divider_px: "Pair divider size (px)",
live_pair_divider_color: "Pair divider color",
live_pair_min_gap_percent: "Pair minimum gap (% of album)",
};
function humanizeOption(value) {
@@ -1189,6 +1192,19 @@ function createAlbumSlideshowCardEditorClass(Base) {
if (s.pair_divider_color) {
items.push({ name: "live_pair_divider_color", selector: { text: {} } });
}
if (s.pair_min_gap_percent) {
items.push({
name: "live_pair_min_gap_percent",
selector: {
number: this._liveNumberConfig(s.pair_min_gap_percent, {
min: 0,
max: 50,
step: 1,
unit: "%",
}),
},
});
}
return items;
}
@@ -1401,7 +1417,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
out[`live_${f}`] = e ? e.state : "";
}
}
for (const f of ["slide_interval", "pair_divider_px"]) {
for (const f of ["slide_interval", "pair_divider_px", "pair_min_gap_percent"]) {
if (s[f]) {
const e = st(s[f]);
out[`live_${f}`] = e ? Number(e.state) : null;
@@ -1594,7 +1610,11 @@ function createAlbumSlideshowCardEditorClass(Base) {
entity_id: id,
option: value,
});
} else if (field === "slide_interval" || field === "pair_divider_px") {
} else if (
field === "slide_interval" ||
field === "pair_divider_px" ||
field === "pair_min_gap_percent"
) {
hass.callService("number", "set_value", {
entity_id: id,
value: Number(value),

Some files were not shown because too many files have changed in this diff Show More