203 files
This commit is contained in:
1 parent
279159581d
commit
99467464e4
203 files changed
+2035
-241
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user