251 files

This commit is contained in:
Home Assistant Version Control
2026-10-16 12:38:25 +00:00
parent 3acf0cdf8d
commit 64eb0c647c
251 changed files with 6380 additions and 14560 deletions
@@ -22,6 +22,7 @@ from .const import (
SERVICE_LIST_HIDDEN_PHOTOS,
)
from .store import SlideshowStore
from .viewers import async_register_websocket as async_register_viewer_websocket
_LOGGER = logging.getLogger(__name__)
@@ -336,6 +337,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""
hass.data.setdefault(DOMAIN, {})
await _async_register_card(hass)
async_register_viewer_websocket(hass)
async def _retry_lovelace_resource(_event) -> None:
if hass.data.get(DOMAIN, {}).get("lovelace_resource_registered"):
@@ -456,6 +458,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# itself never has to render a transition burst on the event loop.
await _async_register_card(hass)
# Streams video slides to the card (provider credentials stay server-side).
try:
from .video import async_register_video_view
async_register_video_view(hass)
except Exception: # noqa: BLE001 - stills keep working without it
_LOGGER.exception("Failed to register Album Slideshow video view")
await _async_cleanup_legacy_entities(hass, entry)
store = SlideshowStore()
+251 -17
View File
@@ -36,8 +36,10 @@ from .const import (
)
from . import image_processing as ip
from . import playlist
from . import video
from .coordinator import AlbumCoordinator, MediaItem
from .store import SlideshowStore
from .viewers import async_get_tracker
_LOGGER = logging.getLogger(__name__)
@@ -59,6 +61,13 @@ _SKIP_SEARCH_LIMIT = 30
_MAX_RENDER_ATTEMPTS = 10
# How long a render waits for an item's metadata (faces for the crop,
# caption fields) to be fetched on demand before rendering without it.
# Upcoming slides render ahead of time, so this rarely delays a slide.
_ENRICH_WAIT_SECONDS = 8.0
_MB = 1024 * 1024
# 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
@@ -161,9 +170,34 @@ def _caption_frame(item: MediaItem | None) -> dict[str, Any]:
"longitude": getattr(item, "longitude", None),
"description": getattr(item, "description", None),
**_camera_metadata_attributes(item),
**_file_attributes(item),
}
def _file_attributes(item: MediaItem | None) -> dict[str, Any]:
return {
"filename": getattr(item, "filename", None),
"path": getattr(item, "source_path", None),
}
# Describe the stream a video slide is served as (see
# ``AlbumCoordinator.async_video_info``); ``None`` for photos.
VIDEO_INFO_FIELDS = (
"video_version", "video_bitrate_mbps", "video_size_bytes", "video_content_type",
)
def _is_video(item: Any) -> bool:
"""A video slide (played alone). Live Photos are photos with motion."""
return bool(getattr(item, "video_url", None)) and not getattr(item, "live_photo", False)
def _has_motion(item: Any) -> bool:
"""A video, or a Live Photo whose motion clip can play over its still."""
return bool(getattr(item, "video_url", None))
def _item_faces(item: MediaItem | None) -> tuple[ip.FaceBox, ...] | None:
"""Return the validated face boxes stored on a media item.
@@ -213,6 +247,10 @@ class _DownloadCache:
def put(self, url: str, data: bytes) -> None:
if len(data) > self._max_bytes:
# Item exceeds the entire cache budget; skip caching but don't raise.
_LOGGER.debug(
"Image cache: not caching %.1f MB image, larger than the %.1f MB budget",
len(data) / _MB, self._max_bytes / _MB,
)
return
if url in self._cache:
self._total_bytes -= len(self._cache[url])
@@ -222,13 +260,28 @@ class _DownloadCache:
self._evict()
def resize(self, max_bytes: int) -> None:
self._max_bytes = max(max_bytes, 1)
max_bytes = max(max_bytes, 1)
if max_bytes != self._max_bytes:
_LOGGER.debug(
"Image cache: budget %.1f MB -> %.1f MB (%.1f MB in use)",
self._max_bytes / _MB, max_bytes / _MB, self._total_bytes / _MB,
)
self._max_bytes = max_bytes
self._evict()
def _evict(self) -> None:
count = freed = 0
while self._total_bytes > self._max_bytes and self._cache:
_, data = self._cache.popitem(last=False)
self._total_bytes -= len(data)
count += 1
freed += len(data)
if count:
_LOGGER.debug(
"Image cache: evicted %d image(s) (%.1f MB), now %.1f/%.1f MB, %d cached",
count, freed / _MB, self._total_bytes / _MB, self._max_bytes / _MB,
len(self._cache),
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
@@ -243,6 +296,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
class AlbumSlideshowCamera(Camera):
_attr_should_poll = False
# The signed stream URL and the server's file paths stay out of history.
_unrecorded_attributes = frozenset({"video_url", "current_path"})
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator, store: SlideshowStore) -> None:
super().__init__()
@@ -272,6 +327,10 @@ class AlbumSlideshowCamera(Camera):
# ``horizontal`` (side-by-side, left/right) or ``vertical`` (stacked,
# top/bottom) for a paired frame; None for single frames.
self._last_pair_orientation: str | None = None
# ``{"photo_id", "duration_ms"}`` when the current slide is a video
# (always unpaired); ``_video_url`` is its signed proxy URL.
self._last_video: dict | None = None
self._video_url: str | None = None
# Cached effective playlist (after date filter + ordering). Invalidated
# by any store change or coordinator update.
self._effective_cache: tuple[int, list[MediaItem]] | None = None
@@ -315,6 +374,8 @@ class AlbumSlideshowCamera(Camera):
self._consecutive_failures: int = 0
self._render_task: asyncio.Task | None = None
self._render_settings = self._current_render_settings()
self._viewers = async_get_tracker(hass)
self._unsub_viewers = None
def _on_coordinator_update() -> None:
previous_items = self._effective_cache[1] if self._effective_cache else None
@@ -326,7 +387,14 @@ class AlbumSlideshowCamera(Camera):
coordinator.async_add_listener(_on_coordinator_update)
self._pause_when_unwatched = bool(store.pause_when_unwatched)
def _on_store_change() -> None:
enabled = bool(self.store.pause_when_unwatched)
if enabled and not self._pause_when_unwatched and self._unsub_viewers is not None:
# Cards stay quiet while the setting is off; let them check in.
self._viewers.async_restart_grace(self.entry.entry_id)
self._pause_when_unwatched = enabled
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
self._slide_deadline = None
settings = self._current_render_settings()
@@ -355,12 +423,18 @@ class AlbumSlideshowCamera(Camera):
# offset based on entry_id keeps the pattern stable across
# restarts. Up to ~3 s spread across albums.
startup_delay = (hash(self.entry.entry_id) % 3000) / 1000.0
self._unsub_viewers = self._viewers.async_track(
self.entry.entry_id, self._on_viewers_change
)
self._render_task = self.hass.async_create_background_task(
self._render_loop(initial_delay=startup_delay),
name="album_slideshow_render_loop",
)
async def async_will_remove_from_hass(self) -> None:
if self._unsub_viewers is not None:
self._unsub_viewers()
self._unsub_viewers = None
preload_task = self._preload_task
self._cancel_preload()
if self._render_task is not None:
@@ -396,6 +470,12 @@ class AlbumSlideshowCamera(Camera):
cur = items[self._index] if items and 0 <= self._index < len(items) else None
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
caption_frames = self._caption_frames(cur)
# Paths go out separately as ``current_path``, which isn't recorded.
paths = [frame.get("path") for frame in caption_frames]
caption_frames = [
{key: value for key, value in frame.items() if key != "path"}
for frame in caption_frames
]
captured_at_pair = (
[frame["captured_at"] for frame in caption_frames]
if self._last_pair_frames else None
@@ -425,6 +505,7 @@ class AlbumSlideshowCamera(Camera):
"media_count_total": len(data.get("items", []) or []),
"current_index": self._index,
"current_filename": getattr(cur, "filename", None),
"current_path": paths if self._last_pair_frames else getattr(cur, "source_path", None),
"current_url": getattr(cur, "url", None),
"current_is_portrait": self._last_is_portrait,
"captured_at": captured_at_pair if captured_at_pair else captured_at,
@@ -445,6 +526,17 @@ class AlbumSlideshowCamera(Camera):
# ``pair_orientation`` tells the card how the two halves are laid
# out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
"caption_frames": caption_frames,
# Video slides: the camera image is the poster frame; the card
# plays ``video_url`` over it for ``video_hold_seconds``.
"media_kind": (
("live_photo" if self._last_video.get("live") else "video")
if self._last_video else "image"
),
"video_url": self._video_url if self._last_video else None,
"video_hold_seconds": (
self._slide_hold_seconds() if self._last_video else None
),
**self._video_info_attributes(),
"pair_orientation": self._last_pair_orientation,
"slide_interval": int(self.store.slide_interval),
"fill_mode": self.store.fill_mode,
@@ -455,6 +547,8 @@ class AlbumSlideshowCamera(Camera):
"shuffle_age_bias": int(self.store.shuffle_age_bias),
"missing_date_mode": self.store.missing_date_mode,
"paused": bool(self.store.paused),
"pause_when_unwatched": bool(self.store.pause_when_unwatched),
"paused_unwatched": self._paused_unwatched,
"refresh_hours": int(self.store.refresh_hours),
"aspect_ratio": self.store.aspect_ratio,
"pair_divider_px": int(self.store.pair_divider_px),
@@ -489,7 +583,7 @@ class AlbumSlideshowCamera(Camera):
the slide was rendered still shows.
"""
if not self._last_pair_frames:
return [_caption_frame(cur)]
return [{**_caption_frame(cur), **self._video_info_attributes()}]
photo_ids = self._current_frame.meta.get("photo_ids", []) if self._current_frame else []
if len(photo_ids) != len(self._last_pair_frames):
return self._last_pair_frames
@@ -762,6 +856,8 @@ class AlbumSlideshowCamera(Camera):
self._last_is_portrait = None
self._last_pair_frames = None
self._last_pair_orientation = None
self._last_video = None
self._video_url = None
def blank_frame():
with Image.new("RGB", (16, 9), "black") as image:
@@ -795,12 +891,17 @@ class AlbumSlideshowCamera(Camera):
# that stub out homeassistant.
from homeassistant.components.camera import async_get_still_stream
return await async_get_still_stream(
request,
self.async_camera_image,
self.content_type,
self.frame_interval,
)
# Someone is watching the stream, so "Pause when not viewed" holds off.
close = self._viewers.async_add_stream(self.entry.entry_id)
try:
return await async_get_still_stream(
request,
self.async_camera_image,
self.content_type,
self.frame_interval,
)
finally:
close()
# Older HA cores may dispatch via the alt name; alias for compatibility.
async def async_handle_async_mjpeg_stream(self, request):
@@ -874,9 +975,20 @@ class AlbumSlideshowCamera(Camera):
try:
composed, meta = await renderer._compose_for_index(items)
meta = dict(meta or {})
meta.setdefault("photo_ids", [getattr(items[renderer._index], "photo_id", None)])
shown = items[renderer._index]
meta.setdefault("photo_ids", [getattr(shown, "photo_id", None)])
# Faces used for the crop, so later face data can be detected.
meta.setdefault("faces", (_item_faces(items[renderer._index]),))
meta.setdefault("faces", (_item_faces(shown),))
# A paired Live Photo shows only its still.
if _has_motion(shown) and len(meta["photo_ids"]) == 1 and shown.photo_id:
if _is_video(shown):
meta["video"] = {
"photo_id": shown.photo_id,
"duration_ms": shown.duration_ms,
**await self._video_info(shown),
}
else:
meta["video"] = {"photo_id": shown.photo_id, "live": True}
cursor = self._capture_cursor(renderer)
if composed is None:
raise RuntimeError("Image composition returned no frame")
@@ -902,6 +1014,7 @@ class AlbumSlideshowCamera(Camera):
def _apply_frame(self, frame: _RenderedFrame) -> None:
"""Make a rendered frame current and publish it to Home Assistant."""
previous_frame = getattr(self, "_current_frame", None)
self._current_frame = frame
self._framebuffer = frame.data
self.store.last_frame = frame.data
@@ -915,19 +1028,41 @@ class AlbumSlideshowCamera(Camera):
self._last_is_portrait = meta.get("is_portrait")
self._last_pair_frames = meta.get("pair_frames")
self._last_pair_orientation = meta.get("pair_orientation")
previous_video = getattr(self, "_last_video", None)
self._last_video = meta.get("video")
if not self._last_video:
self._video_url = None
elif not (
previous_video
and previous_video.get("photo_id") == self._last_video["photo_id"]
and getattr(self, "_video_url", None)
):
# Re-rendering the same clip (metadata refreshes do this) keeps its
# URL, so the card lets it play on instead of restarting it.
self._video_url = video.sign_video_path(
self.hass, video.video_path(self.entry.entry_id, self._last_video["photo_id"])
)
self._frame_id += 1
self._frame_serials[self._frame_id] = frame.serial
while len(self._frame_serials) > _FRAME_ID_MEMORY:
del self._frame_serials[next(iter(self._frame_serials))]
if previous_frame is None or previous_frame.meta.get("photo_ids") != meta.get("photo_ids"):
# A different slide is up: restart the clock with its own hold
# (a video's length), whichever path put it here. Re-renders and
# exclusion rebuilds can swap the slide without going through the
# timer; a re-render of the same slide keeps the running clock.
self._slide_deadline = None
self._interrupt_event.set()
_LOGGER.debug(
"Album Slideshow %s: displayed buffered frame_id=%d index=%d "
"previous=%d next=%d",
"previous=%d next=%d hold=%.1fs",
self.entry.title,
self._frame_id,
self._index,
len(self._previous_frames),
len(self._next_frames),
self._slide_hold_seconds(),
)
self.async_write_ha_state()
@@ -1203,7 +1338,7 @@ class AlbumSlideshowCamera(Camera):
if self._timeline_dirty:
return True
try:
if self.store.paused:
if self._held:
await self._interrupt_event.wait()
else:
await asyncio.wait_for(
@@ -1214,6 +1349,33 @@ class AlbumSlideshowCamera(Camera):
except asyncio.TimeoutError:
return False
@property
def _paused_unwatched(self) -> bool:
"""Held by "Pause when not viewed": no card or stream is showing us."""
return bool(self.store.pause_when_unwatched) and self._viewers.is_unwatched(
self.entry.entry_id
)
@property
def _held(self) -> bool:
"""Whether the slide timer is stopped (manual navigation still works)."""
return bool(self.store.paused) or self._paused_unwatched
def _on_viewers_change(self) -> None:
if not self.store.pause_when_unwatched:
return
_LOGGER.debug(
"Album Slideshow %s: %s",
self.entry.title,
"paused, no one is viewing it" if self._paused_unwatched
else "resumed, a viewer is back",
)
# Wake the render loop; a returning viewer gets a full interval on
# the slide they find.
self._slide_deadline = None
self._interrupt_event.set()
self.async_write_ha_state()
async def _render_loop(self, initial_delay: float = 0.0) -> None:
"""Display buffered frames on command/timer and refill them in the background."""
loop = asyncio.get_running_loop()
@@ -1248,7 +1410,7 @@ class AlbumSlideshowCamera(Camera):
while True:
try:
if self._slide_deadline is None:
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
self._slide_deadline = loop.time() + self._slide_hold_seconds()
if self._timeline_dirty:
async with self._navigation_lock:
if self._timeline_dirty:
@@ -1258,7 +1420,7 @@ class AlbumSlideshowCamera(Camera):
interrupted = await self._wait_or_interrupt(
max(0.0, self._slide_deadline - loop.time())
)
if not interrupted and not self.store.paused:
if not interrupted and not self._held:
async with self._navigation_lock:
if (
not self._timeline_dirty
@@ -1266,7 +1428,7 @@ class AlbumSlideshowCamera(Camera):
and loop.time() >= self._slide_deadline
):
await self._show_next_frame()
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
self._slide_deadline = loop.time() + self._slide_hold_seconds()
self._consecutive_failures = 0
except asyncio.CancelledError:
raise
@@ -1279,6 +1441,68 @@ class AlbumSlideshowCamera(Camera):
err,
)
async def _ensure_enriched(self, item: MediaItem) -> None:
"""Fetch an item's metadata before rendering it, if still missing.
Faces steer the crop and caption fields are baked into paired slides,
so a slide rendered before the background pass reaches its photo
would otherwise be missing both. Bounded so a slow server only
delays the slide briefly; the fetch carries on in the background.
"""
enrich = getattr(getattr(self, "coordinator", None), "async_enrich_item", None)
if enrich is None:
return
loop = asyncio.get_running_loop()
started = loop.time()
try:
fetched = await asyncio.wait_for(asyncio.shield(enrich(item)), _ENRICH_WAIT_SECONDS)
if fetched:
_LOGGER.debug(
"Album Slideshow: fetched metadata for %s in %d ms before display",
getattr(item, "filename", None), (loop.time() - started) * 1000,
)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - render without metadata
_LOGGER.debug(
"Album Slideshow: metadata for %s not ready: %r",
getattr(item, "filename", None), err,
)
async def _video_info(self, item: MediaItem) -> dict[str, Any]:
"""Which stream a video is served as; empty when it can't be told."""
fetch = getattr(self.coordinator, "async_video_info", None)
if fetch is None:
return {}
try:
info = await fetch(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001 - a caption must not cost the slide
_LOGGER.debug("Album Slideshow: could not describe video %s: %s", item.filename, err)
return {}
return {field: info.get(field) for field in VIDEO_INFO_FIELDS if field in info}
def _video_info_attributes(self) -> dict[str, Any]:
video_meta = getattr(self, "_last_video", None) or {}
duration_ms = video_meta.get("duration_ms")
return {
"video_duration": duration_ms / 1000 if duration_ms else None,
**{field: video_meta.get(field) for field in VIDEO_INFO_FIELDS},
}
def _slide_hold_seconds(self) -> float:
"""Seconds the current slide stays up: the interval, or the clip's length."""
interval = int(self.store.slide_interval)
if not self._last_video or self._last_video.get("live"):
# Photos, including Live Photos, keep the slide interval.
return float(interval)
return video.video_hold_seconds(
self._last_video.get("duration_ms"),
int(getattr(self.store, "video_max_seconds", interval)),
interval,
)
@property
def _compose_semaphore(self) -> asyncio.Semaphore:
"""Return the domain-wide compose semaphore, creating it on demand.
@@ -1372,6 +1596,7 @@ class AlbumSlideshowCamera(Camera):
):
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
await self._ensure_enriched(cur)
cur_bytes = await self._fetch_bytes(cur.url)
if not cur_bytes:
raise RuntimeError(f"Failed to fetch image: {cur.url}")
@@ -1388,7 +1613,12 @@ class AlbumSlideshowCamera(Camera):
img = None
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_PAIR:
# A video plays alone, so it is never split into a pair.
if (
orientation_mismatch
and portrait_mode == ORIENTATION_MISMATCH_PAIR
and not _is_video(cur)
):
pair = await self._find_next_mismatch_image(
items, is_portrait_canvas, width, height, limit=_PAIR_SEARCH_LIMIT
)
@@ -1485,6 +1715,7 @@ class AlbumSlideshowCamera(Camera):
continue
if self._index != start:
self._do_advance(count, items)
await self._ensure_enriched(cur)
composed = await self._async_image_job(
ip.render_image, img, fill_mode, width, height, self._crop_hints(cur)
)
@@ -1505,6 +1736,7 @@ class AlbumSlideshowCamera(Camera):
height: int,
fill_mode: str,
) -> tuple[Image.Image | None, dict | None]:
await self._ensure_enriched(item)
b = await self._fetch_bytes(item.url)
if not b:
return None, None
@@ -1592,7 +1824,7 @@ class AlbumSlideshowCamera(Camera):
it = items[idx]
tries += 1
if it.url in self._recent_urls:
if it.url in self._recent_urls or _is_video(it):
continue
meta_portrait = ip.is_portrait_item_by_metadata(it)
@@ -1610,6 +1842,8 @@ class AlbumSlideshowCamera(Camera):
continue
if ip.is_portrait_item(it, img) != is_portrait_canvas:
# Only the chosen partner needs faces and caption fields.
await self._ensure_enriched(it)
return img, it
ip.safe_close(img)
@@ -36,8 +36,12 @@ from .const import (
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_INCLUDE_VIDEOS,
CONF_IMMICH_LIVE_PHOTOS,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
DEFAULT_IMMICH_INCLUDE_VIDEOS,
DEFAULT_IMMICH_LIVE_PHOTOS,
IMMICH_IMAGE_SIZE_OPTIONS,
IMMICH_SELECTION_COMPOSITE,
CONF_PHOTOPRISM_URL,
@@ -279,6 +283,18 @@ def _immich_select_schema(
default=_default(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE),
)
] = vol.In(IMMICH_IMAGE_SIZE_OPTIONS)
fields[
vol.Optional(
CONF_IMMICH_INCLUDE_VIDEOS,
default=_default(CONF_IMMICH_INCLUDE_VIDEOS, DEFAULT_IMMICH_INCLUDE_VIDEOS),
)
] = selector.BooleanSelector()
fields[
vol.Optional(
CONF_IMMICH_LIVE_PHOTOS,
default=_default(CONF_IMMICH_LIVE_PHOTOS, DEFAULT_IMMICH_LIVE_PHOTOS),
)
] = selector.BooleanSelector()
return vol.Schema(fields)
@@ -323,6 +339,8 @@ def _parse_immich_select(
CONF_IMMICH_SELECTION_TYPE: IMMICH_SELECTION_COMPOSITE,
CONF_IMMICH_SELECTION_ID: json.dumps(selection, sort_keys=True),
CONF_IMMICH_IMAGE_SIZE: size,
CONF_IMMICH_INCLUDE_VIDEOS: bool(user_input.get(CONF_IMMICH_INCLUDE_VIDEOS)),
CONF_IMMICH_LIVE_PHOTOS: bool(user_input.get(CONF_IMMICH_LIVE_PHOTOS)),
CONF_ALBUM_NAME: name,
CONF_IMMICH_FILTER: raw_filter,
}
@@ -1711,6 +1729,12 @@ class ImmichOptionsFlow(config_entries.OptionsFlow):
CONF_IMMICH_IMAGE_SIZE: entry.data.get(
CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE
),
CONF_IMMICH_INCLUDE_VIDEOS: bool(entry.data.get(
CONF_IMMICH_INCLUDE_VIDEOS, DEFAULT_IMMICH_INCLUDE_VIDEOS
)),
CONF_IMMICH_LIVE_PHOTOS: bool(entry.data.get(
CONF_IMMICH_LIVE_PHOTOS, DEFAULT_IMMICH_LIVE_PHOTOS
)),
}
if entry.data.get(CONF_IMMICH_FILTER):
defaults[CONF_IMMICH_FILTER] = entry.data[CONF_IMMICH_FILTER]
@@ -1739,7 +1763,10 @@ def _immich_source_changed(old: dict[str, Any], new: dict[str, Any]) -> bool:
CONF_IMMICH_FILTER,
CONF_IMMICH_IMAGE_SIZE,
)
return any(old.get(key) != new.get(key) for key in keys)
return any(old.get(key) != new.get(key) for key in keys) or any(
bool(old.get(key)) != bool(new.get(key))
for key in (CONF_IMMICH_INCLUDE_VIDEOS, CONF_IMMICH_LIVE_PHOTOS)
)
async def _async_clear_item_cache(hass: Any, entry_id: str) -> None:
@@ -58,6 +58,13 @@ IMMICH_IMAGE_SIZE_OPTIONS = [
IMMICH_IMAGE_ORIGINAL,
]
DEFAULT_IMMICH_IMAGE_SIZE = IMMICH_IMAGE_PREVIEW
# Immich option: include video assets. The camera still renders a poster
# frame for each video; the dashboard card plays the clip on top of it.
CONF_IMMICH_INCLUDE_VIDEOS = "immich_include_videos"
DEFAULT_IMMICH_INCLUDE_VIDEOS = False
# Immich option: play a Live Photo's motion clip over its still.
CONF_IMMICH_LIVE_PHOTOS = "immich_live_photos"
DEFAULT_IMMICH_LIVE_PHOTOS = False
# Local-folder option: when True (default) the coordinator does best-effort
# reverse geocoding of EXIF GPS coordinates via the public Nominatim
# (OpenStreetMap) endpoint and exposes a human-readable ``location``
@@ -351,6 +358,19 @@ DEFAULT_IMAGE_CACHE_MB = 75
# Previous/Next can swap these JPEGs immediately without downloading,
# composing, or encoding during the button press.
DEFAULT_NAVIGATION_BUFFER_SIZE = 2
# A video slide stays up for the clip's length, capped by this setting.
# Very short clips are held (and looped by the card) for the minimum so a
# one-second clip doesn't flash past.
DEFAULT_VIDEO_MAX_SECONDS = 60
MAX_VIDEO_MAX_SECONDS = 3600
MIN_VIDEO_HOLD_SECONDS = 5
# "Pause when not viewed": each visible card checks in every
# VIEWER_HEARTBEAT_SECONDS (the card hard-codes the same value); the
# slideshow holds once nothing has checked in for VIEWER_GRACE_SECONDS, so
# one or two missed check-ins or a page reload never pause it.
VIEWER_HEARTBEAT_SECONDS = 20
VIEWER_GRACE_SECONDS = 60
WS_VIEWER_HEARTBEAT = "album_slideshow/viewer_heartbeat"
MAX_RESOLUTION_OPTIONS = ["480p", "720p", "1080p", "1440p", "4K (2160p)", "original"]
DEFAULT_MAX_RESOLUTION = "1080p"
+190 -96
View File
@@ -10,6 +10,7 @@ import math
import os
from pathlib import Path
import re
import time
from typing import Any
import aiohttp
@@ -41,8 +42,13 @@ from .const import (
CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID,
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_INCLUDE_VIDEOS,
CONF_IMMICH_LIVE_PHOTOS,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
DEFAULT_IMMICH_INCLUDE_VIDEOS,
DEFAULT_IMMICH_LIVE_PHOTOS,
IMMICH_IMAGE_PREVIEW,
CONF_PHOTOPRISM_URL,
CONF_PHOTOPRISM_AUTH_METHOD,
CONF_PHOTOPRISM_TOKEN,
@@ -162,11 +168,29 @@ class MediaItem:
# True when ``location`` came from our OpenStreetMap lookup rather than
# the provider, so it can be re-formatted from the place cache.
location_geocoded: bool = False
# Upstream playback URL for a video item (``url`` is then its poster
# still), or for a Live Photo's motion clip (``live_photo``; ``url`` is
# the photo). Fetched server-side by the video proxy view only, so
# provider credentials never reach the browser. ``duration_ms`` is the
# clip length (videos only).
video_url: str | None = None
duration_ms: int | None = None
live_photo: bool = False
# Provider file path (Immich ``originalPath``), shown by the card's
# ``path`` caption.
source_path: str | None = None
# Runtime-only description of the stream a video is served as (see
# ``async_video_info``); not persisted.
video_info: dict[str, Any] | None = None
def __post_init__(self) -> None:
if self.photo_id is None:
self.photo_id = _photo_identifier(self.source_id)
@property
def is_video(self) -> bool:
return bool(self.video_url) and not self.live_photo
def _photo_identifier(source_id: str | None) -> str | None:
if not source_id:
@@ -505,9 +529,12 @@ _NAME_PREFIXES = (
"village of ", "town of ", "city of ",
)
# How many EXIF reads to perform between cache flushes. Bigger batches
# mean fewer Store writes but more rework if HA is killed mid-scan.
# How many EXIF reads to perform between listener updates, and the minimum
# time between items-cache writes during a pass (the cache holds every item,
# so writing it per batch is costly on large libraries; the pass still saves
# when it finishes or is cancelled).
_EXIF_BATCH_SAVE = 25
_ENRICHMENT_SAVE_INTERVAL = 60.0
_GEOCODE_BATCH_SAVE = 10
# Cap a single Nextcloud enrichment download. EXIF/IPTC/XMP live in the first
@@ -1145,6 +1172,11 @@ class AlbumCoordinator(DataUpdateCoordinator):
CONF_IMMICH_URL, CONF_IMMICH_API_KEY, CONF_IMMICH_SELECTION_TYPE,
CONF_IMMICH_SELECTION_ID, CONF_IMMICH_FILTER, CONF_IMMICH_IMAGE_SIZE,
)]
# Appended only when enabled so existing caches stay valid.
if entry.data.get(CONF_IMMICH_INCLUDE_VIDEOS):
source.append("videos")
if entry.data.get(CONF_IMMICH_LIVE_PHOTOS):
source.append("live_photos")
self._immich_cache_source = sha256(json.dumps(source).encode()).hexdigest()
self.album_url: str | None = entry.data.get(CONF_ALBUM_URL)
self.local_path: str | None = entry.data.get(CONF_LOCAL_PATH)
@@ -1208,6 +1240,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
"geocode_total": 0,
"geocode_done": 0,
}
# Per-item enrichment in flight, shared by the background pass and
# on-demand requests from the camera so an item is fetched once.
self._enrich_inflight: dict[int, asyncio.Task] = {}
self._last_items_save: float | None = None
super().__init__(
hass,
@@ -1526,6 +1562,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
} if isinstance(raw.get("camera_metadata"), dict) else None,
google_location_scanned=bool(raw.get("google_location_scanned", False)),
location_geocoded=bool(raw.get("location_geocoded", False)),
video_url=raw.get("video_url") if isinstance(raw.get("video_url"), str) else None,
duration_ms=raw.get("duration_ms") if isinstance(raw.get("duration_ms"), int) else None,
source_path=raw.get("source_path") if isinstance(raw.get("source_path"), str) else None,
live_photo=bool(raw.get("live_photo", False)),
))
except Exception:
continue
@@ -1570,6 +1610,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
"camera_metadata": it.camera_metadata,
"google_location_scanned": it.google_location_scanned,
"location_geocoded": it.location_geocoded,
"video_url": it.video_url,
"duration_ms": it.duration_ms,
"source_path": it.source_path,
"live_photo": it.live_photo,
}
for it in items
],
@@ -1811,6 +1855,12 @@ class AlbumCoordinator(DataUpdateCoordinator):
sel_type = self.entry.data.get(CONF_IMMICH_SELECTION_TYPE)
sel_id = self.entry.data.get(CONF_IMMICH_SELECTION_ID)
size = self.entry.data.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE)
include_videos = bool(
self.entry.data.get(CONF_IMMICH_INCLUDE_VIDEOS, DEFAULT_IMMICH_INCLUDE_VIDEOS)
)
live_photos = bool(
self.entry.data.get(CONF_IMMICH_LIVE_PHOTOS, DEFAULT_IMMICH_LIVE_PHOTOS)
)
if not url or not api_key or not sel_type:
raise UpdateFailed("Immich provider is missing URL, API key, or selection")
@@ -1837,7 +1887,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
self.image_request_headers = dict(client.image_headers)
try:
assets = await client.async_collect_assets(sel_type, sel_id, filter_body)
assets = await client.async_collect_assets(
sel_type, sel_id, filter_body, include_videos
)
except Exception as err:
raise UpdateFailed(f"Error querying Immich: {err}") from err
@@ -1854,18 +1906,46 @@ class AlbumCoordinator(DataUpdateCoordinator):
) or immich_api._to_epoch_ms(a.get("fileCreatedAt"))
w = a.get("width")
h = a.get("height")
is_video = immich_api.is_video_item(a)
motion_id = immich_api.live_photo_video_id(a) if live_photos else None
if is_video:
video_url = immich_api.build_video_url(client.base_url, aid)
elif motion_id:
video_url = immich_api.build_video_url(client.base_url, motion_id)
else:
video_url = None
items.append(
MediaItem(
url=immich_api.build_image_url(client.base_url, aid, size),
# A video's poster still is its preview thumbnail: the
# ``original`` endpoint would return the video file and
# ``fullsize`` is only generated for images.
url=immich_api.build_image_url(
client.base_url, aid, IMMICH_IMAGE_PREVIEW if is_video else size
),
width=w if isinstance(w, int) else None,
height=h if isinstance(h, int) else None,
mime_type=None,
filename=a.get("originalFileName"),
captured_at=captured,
source_id=aid,
video_url=video_url,
live_photo=bool(motion_id),
duration_ms=(
immich_api.parse_duration_ms(a.get("duration")) if is_video else None
),
source_path=(
a.get("originalPath") if isinstance(a.get("originalPath"), str) else None
),
)
)
_LOGGER.debug(
"Immich %s: %d assets (%d videos, %d Live Photos)",
self.entry.title,
len(items),
sum(1 for item in items if item.is_video),
sum(1 for item in items if item.live_photo),
)
return {
"title": self.entry.title,
"items": items,
@@ -2592,6 +2672,33 @@ class AlbumCoordinator(DataUpdateCoordinator):
return candidate, {}
return None, {}
async def async_video_info(self, item: MediaItem) -> dict[str, Any]:
"""Describe the stream a video slide is served as; cached per item.
Used for the card's ``video_version`` caption: whether Immich serves
its transcoded copy or the original, and the stream's bitrate.
"""
if item.video_info is not None:
return item.video_info
if self.provider != PROVIDER_IMMICH or not item.source_id or not item.is_video:
return {}
from . import immich as immich_api
client = immich_api.ImmichClient(
self.hass,
self.entry.data.get(CONF_IMMICH_URL),
self.entry.data.get(CONF_IMMICH_API_KEY),
)
content_type, served = await client.async_probe_video(item.source_id)
if item.byte_size is None:
# Enrichment may not have reached this item yet.
info = immich_api.parse_asset_exif(await client.async_get_asset(item.source_id))
item.byte_size = info.get("byte_size")
item.video_info = immich_api.describe_video(
content_type, served, item.byte_size, item.duration_ms
)
return item.video_info
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch Immich metadata and the face boxes used for cropping."""
from . import immich as immich_api
@@ -2642,6 +2749,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.location_geocoded = False
if "description" in info:
item.description = info["description"]
if "byte_size" in info:
item.byte_size = info["byte_size"]
item.exif_scanned = True
else:
if asset is None:
@@ -2798,6 +2907,73 @@ class AlbumCoordinator(DataUpdateCoordinator):
item.location_geocoded = False
item.exif_scanned = True
async def async_enrich_item(self, item: MediaItem) -> bool:
"""Enrich one item now, sharing the work with the background pass.
The camera calls this just before rendering a slide so faces (for
cropping) and caption metadata are in place without waiting for the
pass to reach the item. Concurrent callers await the same fetch.
Returns whether metadata was fetched (``False``: nothing to do).
"""
if self.provider == PROVIDER_GOOGLE_SHARED or not self._needs_enrichment(item):
return False
inflight = getattr(self, "_enrich_inflight", None)
if inflight is None:
inflight = self._enrich_inflight = {}
key = id(item)
task = inflight.get(key)
if task is None:
task = self.hass.async_create_background_task(
self._enrich_one(item),
name=f"album_slideshow_enrich_item_{self.entry.entry_id}",
)
inflight[key] = task
task.add_done_callback(lambda _task: inflight.pop(key, None))
# Shielded: a cancelled caller (e.g. a dropped preload) must not
# abandon a fetch another caller is waiting on.
return await asyncio.shield(task)
async def _enrich_one(self, item: MediaItem) -> bool:
"""Fetch one item's metadata from its provider; never raises."""
try:
if self.provider == PROVIDER_IMMICH:
await self._enrich_immich_item(item)
elif self.provider == PROVIDER_NEXTCLOUD:
await self._enrich_nextcloud_item(item)
elif self.provider == PROVIDER_UGREEN:
await self._enrich_ugreen_item(item)
elif item.url.startswith("file://"):
path = Path(item.url[len("file://"):])
info = await self.hass.async_add_executor_job(_read_local_exif, path)
if "captured_at" in info:
item.captured_at = info["captured_at"]
if "description" in info:
item.description = info["description"]
if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"]
item.longitude = info["longitude"]
item.exif_scanned = True
else:
# Nothing to read for remote files without a provider API.
item.exif_scanned = True
return False
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("%s enrich error: %s", self.provider, err)
item.exif_scanned = True
self._enrich_progress["exif_done"] = self._enrich_progress.get("exif_done", 0) + 1
return True
async def _flush_enrichment(self, data: dict[str, Any]) -> None:
"""Persist progress (at most once a minute) and notify listeners."""
now = time.monotonic()
last = getattr(self, "_last_items_save", None)
if last is None or now - last >= _ENRICHMENT_SAVE_INTERVAL:
self._last_items_save = now
await self._save_cached_items(data)
self.async_set_updated_data(data)
async def _enrich_items_background(self, data: dict[str, Any]) -> None:
"""Read EXIF for unscanned local files, then reverse-geocode.
@@ -2813,110 +2989,28 @@ class AlbumCoordinator(DataUpdateCoordinator):
if not items:
return
scanned_since_save = 0
pending = 0
try:
for idx, item in enumerate(items):
if not self._needs_enrichment(item):
# Fast path: yield occasionally so we don't starve
# the event loop when (re-)visiting a long list of
# already-processed items.
# already-processed items (including ones the camera
# enriched on demand).
if idx % _ENRICHMENT_FAST_PATH_YIELD_EVERY == 0:
await asyncio.sleep(0)
continue
if self.provider == PROVIDER_IMMICH:
try:
await self._enrich_immich_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("Immich enrich error: %s", err)
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
if not await self.async_enrich_item(item):
continue
if self.provider == PROVIDER_NEXTCLOUD:
try:
await self._enrich_nextcloud_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("Nextcloud enrich error: %s", err)
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
continue
if self.provider == PROVIDER_UGREEN:
try:
await self._enrich_ugreen_item(item)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("UGREEN enrich error: %s", err)
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
continue
url = item.url
if not url.startswith("file://"):
item.exif_scanned = True
continue
path = Path(url[len("file://"):])
try:
info = await self.hass.async_add_executor_job(
_read_local_exif, path
)
except asyncio.CancelledError:
raise
except Exception as err: # noqa: BLE001
_LOGGER.debug("EXIF: executor error for %s: %s", path, err)
info = {}
if "captured_at" in info:
item.captured_at = info["captured_at"]
if "description" in info:
item.description = info["description"]
if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"]
item.longitude = info["longitude"]
item.exif_scanned = True
scanned_since_save += 1
self._enrich_progress["exif_done"] = (
self._enrich_progress.get("exif_done", 0) + 1
)
if scanned_since_save >= _EXIF_BATCH_SAVE:
scanned_since_save = 0
await self._save_cached_items(data)
self.async_set_updated_data(data)
pending += 1
if pending >= _EXIF_BATCH_SAVE:
pending = 0
await self._flush_enrichment(data)
# Flush any tail items before the geocode phase.
if scanned_since_save:
await self._save_cached_items(data)
self.async_set_updated_data(data)
if pending:
await self._flush_enrichment(data)
await self._geocode_items_background(data)
except asyncio.CancelledError:
+195 -42
View File
@@ -18,6 +18,11 @@ API shape (Immich v1.13x / v3, ``/api`` prefix, ``x-api-key`` header):
keep faces whole in cover-mode crops.
- Image bytes: ``/api/assets/{id}/thumbnail?size=preview|fullsize`` or
``/api/assets/{id}/original`` (all require the ``x-api-key`` header).
- Video bytes: ``/api/assets/{id}/video/playback`` - the browser-friendly
(transcoded when Immich's policy requires it) stream; honours ``Range``.
Video list items carry ``duration``: integer milliseconds on current
servers, an ``"H:MM:SS.ffffff"`` string on older ones. A Live Photo is an
image whose ``livePhotoVideoId`` names its (hidden) motion-clip asset.
"""
from __future__ import annotations
@@ -57,6 +62,97 @@ def build_image_url(base_url: str, asset_id: str, size: str) -> str:
return f"{base}/api/assets/{asset_id}/thumbnail?size={thumb_size}"
def build_video_url(base_url: str, asset_id: str) -> str:
"""Build the playback URL for a video asset (key sent as a header)."""
return f"{normalize_base_url(base_url)}/api/assets/{asset_id}/video/playback"
def is_video_item(item: Any) -> bool:
return isinstance(item, dict) and str(item.get("type", "")).upper() == "VIDEO"
def live_photo_video_id(item: Any) -> str | None:
"""The motion-clip asset id of a Live Photo, or ``None``."""
if not isinstance(item, dict) or is_video_item(item):
return None
value = item.get("livePhotoVideoId")
return value if isinstance(value, str) and value else None
def stream_total_bytes(status: int, headers: Any) -> int | None:
"""Total size of a stream from a ranged probe's response headers.
A ``206`` carries it after the slash in ``Content-Range``
(``bytes 0-0/12345``); a server that ignores ``Range`` answers ``200``
with the full ``Content-Length``.
"""
raw = None
if status == 206:
content_range = headers.get("Content-Range") or ""
raw = content_range.rpartition("/")[2]
elif status == 200:
raw = headers.get("Content-Length")
try:
total = int(raw)
except (TypeError, ValueError):
return None
return total if total > 0 else None
def describe_video(
content_type: str | None,
served_bytes: int | None,
original_bytes: int | None,
duration_ms: int | None,
) -> dict[str, Any]:
"""Summarise the stream Immich serves for a video.
Immich's playback endpoint serves its transcoded copy when one exists and
the original file otherwise, so a stream the same size as the original is
the original. The bitrate is the stream's average over the clip.
"""
version = None
if served_bytes and original_bytes:
version = "original" if served_bytes == original_bytes else "transcoded"
bitrate = None
if served_bytes and duration_ms:
bitrate = round(served_bytes * 8 / (duration_ms / 1000) / 1_000_000, 1)
return {
"video_version": version,
"video_bitrate_mbps": bitrate,
"video_size_bytes": served_bytes,
"video_content_type": (content_type or "").split(";", 1)[0].strip() or None,
}
def parse_duration_ms(value: Any) -> int | None:
"""Parse an Immich video duration to milliseconds.
Current servers send integer milliseconds; older ones send an
``"H:MM:SS.ffffff"`` string. Returns ``None`` for missing, malformed or
zero durations (images report ``null``, ``0`` or ``"0:00:00.00000"``).
"""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
if not math.isfinite(value) or value <= 0:
return None
return int(round(value))
if not isinstance(value, str) or not value:
return None
parts = value.strip().split(":")
if len(parts) != 3:
return None
try:
hours, minutes, seconds = int(parts[0]), int(parts[1]), float(parts[2])
except ValueError:
return None
if hours < 0 or minutes < 0 or not math.isfinite(seconds) or seconds < 0:
return None
total = int(round((hours * 3600 + minutes * 60 + seconds) * 1000))
return total if total > 0 else None
def _to_epoch_ms(value: Any) -> int | None:
"""Parse an ISO-8601 timestamp to epoch milliseconds, or ``None``."""
if not isinstance(value, str) or not value:
@@ -93,17 +189,20 @@ def location_label(city: Any, state: Any, country: Any) -> str | None:
return ", ".join(parts) if parts else None
def parse_search_page(payload: Any) -> tuple[list[dict[str, Any]], int | None]:
"""Return ``(image_items, next_page)`` from a search/metadata response.
def parse_search_page(
payload: Any, include_videos: bool = False
) -> tuple[list[dict[str, Any]], int | None]:
"""Return ``(media_items, next_page)`` from a search/metadata response.
Filters out non-image assets and anything trashed/archived. ``next_page``
is the page number to request next, or ``None`` when done.
Filters out non-image assets (videos too unless ``include_videos``) and
anything trashed/archived. ``next_page`` is the page number to request
next, or ``None`` when done.
"""
assets = (payload or {}).get("assets") if isinstance(payload, dict) else None
if not isinstance(assets, dict):
return [], None
items = assets.get("items")
out = _filter_image_items(items)
out = _filter_image_items(items, include_videos)
next_page = assets.get("nextPage")
if isinstance(next_page, str) and next_page.isdigit():
next_page = int(next_page)
@@ -112,51 +211,74 @@ def parse_search_page(payload: Any) -> tuple[list[dict[str, Any]], int | None]:
return out, next_page
def parse_random(payload: Any) -> list[dict[str, Any]]:
def parse_random(payload: Any, include_videos: bool = False) -> list[dict[str, Any]]:
"""Return image items from a ``/api/search/random`` response.
``search/random`` returns a plain list of assets (no pagination wrapper).
"""
if isinstance(payload, list):
return _filter_image_items(payload)
return _filter_image_items(payload, include_videos)
# Some cores wrap it like search/metadata; handle that too.
if isinstance(payload, dict):
assets = payload.get("assets")
if isinstance(assets, dict):
return _filter_image_items(assets.get("items"))
return _filter_image_items(assets.get("items"), include_videos)
return []
def _filter_image_items(items: Any) -> list[dict[str, Any]]:
"""Keep only non-trashed, non-archived image assets with an id."""
def _filter_image_items(items: Any, include_videos: bool = False) -> list[dict[str, Any]]:
"""Keep only visible, non-trashed, non-archived image (or video) assets.
Hidden assets are Live Photo motion clips; they play with their still
(see ``live_photo_video_id``), never as videos of their own.
"""
allowed = {"IMAGE", "VIDEO"} if include_videos else {"IMAGE"}
out: list[dict[str, Any]] = []
if isinstance(items, list):
for it in items:
if not isinstance(it, dict):
continue
if str(it.get("type", "")).upper() != "IMAGE":
if str(it.get("type", "")).upper() not in allowed:
continue
if it.get("isTrashed") or it.get("isArchived"):
continue
if it.get("visibility") == "hidden" or it.get("isVisible") is False:
continue
if not it.get("id"):
continue
out.append(it)
return out
def constrain_type(body: dict[str, Any], include_videos: bool = False) -> dict[str, Any]:
"""Limit a search body to the asset types the slideshow can show.
Without videos the body is forced to ``IMAGE``. With videos, an explicit
``IMAGE``/``VIDEO`` type (from a custom filter) is honoured; anything
else is dropped so Immich returns both kinds.
"""
if not include_videos:
body["type"] = "IMAGE"
elif str(body.get("type", "")).upper() not in ("IMAGE", "VIDEO"):
body.pop("type", None)
return body
def build_search_body(
selection_type: str, selection_id: str | None, filter_body: dict | None
selection_type: str,
selection_id: str | None,
filter_body: dict | None,
include_videos: bool = False,
) -> dict[str, Any]:
"""Build the ``search/metadata`` request body for a selection.
Always constrains to images. For ``search`` the user-supplied filter is
used as a base (with ``type`` forced to IMAGE). ``album``/``person`` add
the id filter; ``favorites`` sets ``isFavorite``; ``all`` adds nothing.
Constrains to images (and videos when enabled). For ``search`` the
user-supplied filter is used as a base. ``album``/``person`` add the id
filter; ``favorites`` sets ``isFavorite``; ``all`` adds nothing.
"""
body: dict[str, Any] = {"type": "IMAGE"}
body: dict[str, Any] = {}
if selection_type == "search" and isinstance(filter_body, dict):
body = dict(filter_body)
body["type"] = "IMAGE"
elif selection_type == "album" and selection_id:
body["albumIds"] = [selection_id]
elif selection_type == "person" and selection_id:
@@ -164,7 +286,7 @@ def build_search_body(
elif selection_type == "favorites":
body["isFavorite"] = True
# ``all`` -> no extra filter (whole library).
return body
return constrain_type(body, include_videos)
def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
@@ -189,7 +311,9 @@ def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
def build_composite_bodies(
selection_id: str | None, filter_body: dict | None = None
selection_id: str | None,
filter_body: dict | None = None,
include_videos: bool = False,
) -> list[dict[str, Any]]:
"""Build one ``search/metadata`` body per composite union member.
@@ -201,18 +325,16 @@ def build_composite_bodies(
sel = parse_composite_selection(selection_id)
bodies: list[dict[str, Any]] = []
for aid in sel["albums"]:
bodies.append({"type": "IMAGE", "albumIds": [aid]})
bodies.append({"albumIds": [aid]})
for pid in sel["people"]:
bodies.append({"type": "IMAGE", "personIds": [pid]})
bodies.append({"personIds": [pid]})
if sel["favorites"]:
bodies.append({"type": "IMAGE", "isFavorite": True})
bodies.append({"isFavorite": True})
if isinstance(filter_body, dict) and filter_body:
member = dict(filter_body)
member["type"] = "IMAGE"
bodies.append(member)
bodies.append(dict(filter_body))
if not bodies:
bodies.append({"type": "IMAGE"})
return bodies
bodies.append({})
return [constrain_type(body, include_videos) for body in bodies]
def parse_asset_exif(asset: Any) -> dict[str, Any]:
@@ -241,6 +363,9 @@ def parse_asset_exif(asset: Any) -> dict[str, Any]:
desc = exif.get("description")
if isinstance(desc, str) and desc.strip():
out["description"] = desc.strip()
size = exif.get("fileSizeInByte")
if isinstance(size, int) and not isinstance(size, bool) and size > 0:
out["byte_size"] = size
return out
@@ -489,48 +614,58 @@ class ImmichClient:
selection_type: str,
selection_id: str | None = None,
filter_body: dict | None = None,
include_videos: bool = False,
) -> list[dict[str, Any]]:
"""Collect image assets for a selection.
"""Collect image (and optionally video) assets for a selection.
``random`` uses ``/api/search/random`` (a single, unpaginated batch).
Everything else pages through ``/api/search/metadata`` with a body
built from the selection.
"""
if selection_type == "random":
body = {"size": min(_PAGE_SIZE, 250), "type": "IMAGE"}
body: dict[str, Any] = {"size": min(_PAGE_SIZE, 250)}
if isinstance(filter_body, dict):
merged = dict(filter_body)
merged.update(body)
body = merged
constrain_type(body, include_videos)
payload = await self._post("/api/search/random", body)
return parse_random(payload)
return parse_random(payload, include_videos)
if selection_type == "people":
# Immich treats multiple personIds in one query as AND (only photos
# where everyone appears together). To get OR (any of the people),
# query each person separately and union by asset id. See #19.
ids = [p for p in (selection_id or "").split(",") if p]
bodies = [{"type": "IMAGE", "personIds": [p]} for p in ids]
return await self._collect_union(bodies)
bodies = [
constrain_type({"personIds": [p]}, include_videos) for p in ids
]
return await self._collect_union(bodies, include_videos)
if selection_type == "albums":
# Same OR behavior for a set of albums: query each album on its own
# and union the results, deduped by asset id.
ids = [a for a in (selection_id or "").split(",") if a]
bodies = [{"type": "IMAGE", "albumIds": [a]} for a in ids]
return await self._collect_union(bodies)
bodies = [
constrain_type({"albumIds": [a]}, include_videos) for a in ids
]
return await self._collect_union(bodies, include_videos)
if selection_type == "composite":
# A mix of albums, people, favorites and/or a custom filter. Each
# is queried on its own and unioned; an empty composite means the
# whole library. See #19.
bodies = build_composite_bodies(selection_id, filter_body)
return await self._collect_union(bodies)
bodies = build_composite_bodies(selection_id, filter_body, include_videos)
return await self._collect_union(bodies, include_videos)
base = build_search_body(selection_type, selection_id, filter_body)
return await self._collect_metadata(base)
base = build_search_body(
selection_type, selection_id, filter_body, include_videos
)
return await self._collect_metadata(base, include_videos)
async def _collect_metadata(self, base: dict[str, Any]) -> list[dict[str, Any]]:
async def _collect_metadata(
self, base: dict[str, Any], include_videos: bool = False
) -> list[dict[str, Any]]:
"""Page through ``search/metadata`` for a prebuilt body."""
collected: list[dict[str, Any]] = []
page: int | None = 1
@@ -539,13 +674,13 @@ class ImmichClient:
body["size"] = _PAGE_SIZE
body["page"] = page
payload = await self._post("/api/search/metadata", body)
items, next_page = parse_search_page(payload)
items, next_page = parse_search_page(payload, include_videos)
collected.extend(items)
page = next_page
return collected
async def _collect_union(
self, bodies: list[dict[str, Any]]
self, bodies: list[dict[str, Any]], include_videos: bool = False
) -> list[dict[str, Any]]:
"""Union several ``search/metadata`` queries (OR), deduped by asset id.
@@ -558,7 +693,7 @@ class ImmichClient:
for body in bodies:
if len(out) >= _MAX_ASSETS:
break
items = await self._collect_metadata(body)
items = await self._collect_metadata(body, include_videos)
for it in items:
aid = it.get("id")
if aid and aid not in seen:
@@ -569,6 +704,24 @@ class ImmichClient:
async def async_get_asset(self, asset_id: str) -> dict[str, Any]:
return await self._get(f"/api/assets/{asset_id}")
async def async_probe_video(self, asset_id: str) -> tuple[str | None, int | None]:
"""Return the playback stream's ``(content_type, total_bytes)``.
Requests a single byte; the body is never read, so this stays cheap
even against a server that ignores ``Range``.
"""
session = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with session.get(
build_video_url(self.base_url, asset_id),
headers={**self.image_headers, "Range": "bytes=0-0"},
) as resp:
resp.raise_for_status()
return (
resp.headers.get("Content-Type"),
stream_total_bytes(resp.status, resp.headers),
)
async def async_get_faces(self, asset_id: str) -> list[dict[str, Any]]:
data = await self._get(f"/api/faces?id={asset_id}")
if not isinstance(data, list):
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["PyNaCl>=1.5.0"],
"version": "1.15.0"
"version": "1.15.1"
}
+45 -13
View File
@@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN, MAX_CUSTOM_LOOKBACK_DAYS
from .const import DOMAIN, MAX_CUSTOM_LOOKBACK_DAYS, MAX_VIDEO_MAX_SECONDS, PROVIDER_IMMICH
from .coordinator import AlbumCoordinator
from .store import SlideshowStore
@@ -15,18 +15,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
async_add_entities(
[
SlideIntervalNumber(entry, store),
RefreshHoursNumber(entry, store, coordinator),
PairDividerWidthNumber(entry, store),
PairMinGapPercentNumber(entry, store),
NavigationBufferSizeNumber(entry, store),
ImageCacheMbNumber(entry, store),
CustomLookbackDaysNumber(entry, store),
ShuffleAgeBiasNumber(entry, store),
]
)
entities = [
SlideIntervalNumber(entry, store),
RefreshHoursNumber(entry, store, coordinator),
PairDividerWidthNumber(entry, store),
PairMinGapPercentNumber(entry, store),
NavigationBufferSizeNumber(entry, store),
ImageCacheMbNumber(entry, store),
CustomLookbackDaysNumber(entry, store),
ShuffleAgeBiasNumber(entry, store),
]
# Only providers that can surface videos get the video-length setting.
if getattr(coordinator, "provider", None) == PROVIDER_IMMICH:
entities.append(VideoMaxSecondsNumber(entry, store))
async_add_entities(entities)
class _BaseNumber(NumberEntity, RestoreEntity):
@@ -111,6 +113,36 @@ class ShuffleAgeBiasNumber(_BaseNumber):
return
class VideoMaxSecondsNumber(_BaseNumber):
_attr_icon = "mdi:video-outline"
_attr_native_min_value = 5
_attr_native_max_value = MAX_VIDEO_MAX_SECONDS
_attr_native_step = 1
_attr_native_unit_of_measurement = "s"
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
super().__init__(entry, store)
self._attr_unique_id = f"{entry.entry_id}_video_max_seconds"
self._attr_name = "Max video length"
@property
def native_value(self):
return int(self.store.video_max_seconds)
async def async_set_native_value(self, value: float) -> None:
self.store.video_max_seconds = max(5, min(MAX_VIDEO_MAX_SECONDS, 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:
await self.async_set_native_value(float(old.state))
except (TypeError, ValueError, OverflowError):
return
class SlideIntervalNumber(_BaseNumber):
_attr_icon = "mdi:timer-outline"
_attr_native_min_value = 3
@@ -24,6 +24,7 @@ from .const import (
DEFAULT_DATE_FILTER,
DEFAULT_CUSTOM_LOOKBACK_DAYS,
DEFAULT_MISSING_DATE_MODE,
DEFAULT_VIDEO_MAX_SECONDS,
)
@@ -45,6 +46,7 @@ class SlideshowStore:
image_cache_mb: int = DEFAULT_IMAGE_CACHE_MB
navigation_buffer_size: int = DEFAULT_NAVIGATION_BUFFER_SIZE
max_resolution: str = DEFAULT_MAX_RESOLUTION
video_max_seconds: int = DEFAULT_VIDEO_MAX_SECONDS
# Date filter mode (preset windows like this_year / on_this_day).
date_filter: str = DEFAULT_DATE_FILTER
@@ -56,6 +58,9 @@ class SlideshowStore:
# Pause toggle - when True, the slideshow holds on the current frame.
paused: bool = False
# Hold the slideshow while no dashboard card or live stream is showing it.
pause_when_unwatched: bool = False
# Draw face boxes, a centre crosshair and a crop summary on each slide.
face_debug: bool = False
@@ -50,7 +50,9 @@
"people": "People",
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
"immich_image_size": "Image quality",
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)"
}
},
"photoprism": {
@@ -225,6 +227,8 @@
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality",
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)",
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap",
"hide_home_country": "Hide the country for places in your home country"
}
+48 -1
View File
@@ -13,7 +13,11 @@ from .store import SlideshowStore
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]
async_add_entities([PauseSwitch(entry, store), CropDebugSwitch(entry, store)])
async_add_entities([
PauseSwitch(entry, store),
PauseWhenUnwatchedSwitch(entry, store),
CropDebugSwitch(entry, store),
])
class PauseSwitch(SwitchEntity, RestoreEntity):
@@ -56,6 +60,49 @@ class PauseSwitch(SwitchEntity, RestoreEntity):
self.store.notify()
class PauseWhenUnwatchedSwitch(SwitchEntity, RestoreEntity):
"""Hold the slideshow while no card or live stream is showing it."""
_attr_has_entity_name = True
_attr_should_poll = False
_attr_icon = "mdi:eye-off-outline"
_attr_entity_category = EntityCategory.CONFIG
def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None:
self.entry = entry
self.store = store
self._attr_unique_id = f"{entry.entry_id}_pause_when_unwatched"
self._attr_name = "Pause when not viewed"
@property
def is_on(self) -> bool:
return bool(self.store.pause_when_unwatched)
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.entry.entry_id)},
"name": f"Album Slideshow {self.entry.title}",
"manufacturer": "Album Slideshow",
}
async def async_turn_on(self, **kwargs) -> None:
self.store.pause_when_unwatched = True
self.store.notify()
async def async_turn_off(self, **kwargs) -> None:
self.store.pause_when_unwatched = False
self.store.notify()
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.store.add_listener(self.async_write_ha_state)
old = await self.async_get_last_state()
if old is not None and old.state in ("on", "off"):
self.store.pause_when_unwatched = old.state == "on"
self.store.notify()
class CropDebugSwitch(SwitchEntity, RestoreEntity):
"""Overlay face boxes, a centre crosshair and a crop summary on slides."""
@@ -50,7 +50,9 @@
"people": "People",
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
"immich_image_size": "Image quality",
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)"
}
},
"photoprism": {
@@ -225,6 +227,8 @@
"favorites": "Include favorites",
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality",
"immich_include_videos": "Include videos (played by the Album Slideshow card)",
"immich_live_photos": "Play Live Photo motion (played by the Album Slideshow card)",
"reverse_geocode": "Look up EXIF GPS locations via OpenStreetMap",
"hide_home_country": "Hide the country for places in your home country"
}
@@ -24,9 +24,16 @@
* background: '' # CSS color shown behind contained images.
* # Empty inherits theme card background.
* tap_action: none # none | more-info
* videos: true # play video slides (muted, looping) and
* # Live Photo motion over the camera's still;
* # false shows only the still.
* video_audio: off # off | muted | on. ``muted`` starts videos
* # muted with a speaker button to unmute;
* # ``on`` plays with sound when the browser
* # allows it, falling back to that button.
*/
const VERSION = "1.15.0";
const VERSION = "1.15.1";
const ANIMATED_TRANSITIONS = [
"fade",
@@ -46,12 +53,31 @@ const TRANSITIONS = new Set(["random", "none", ...ANIMATED_TRANSITIONS]);
const FIT_MODES = new Set(["auto", "cover", "contain"]);
// While on screen, the card tells the integration it is being viewed so
// "Pause when not viewed" can hold the slideshow when nobody is looking.
// Matches VIEWER_HEARTBEAT_SECONDS in const.py.
const VIEWER_HEARTBEAT_MS = 20000;
const PHOTO_CONTROL_OPTIONS = [
{ value: "off", label: "Off" },
{ value: "on_demand", label: "On demand" },
{ value: "always", label: "Always" },
];
const VIDEO_AUDIO_OPTIONS = [
{ value: "off", label: "Off" },
{ value: "muted", label: "Muted, tap to unmute" },
{ value: "on", label: "On" },
];
/** ``true``/``false`` from earlier versions mean ``on``/``off``. */
function normalizeVideoAudio(value) {
if (value === true) return "on";
if (value == null || value === false) return "off";
if (VIDEO_AUDIO_OPTIONS.some((option) => option.value === value)) return value;
throw new Error(`album-slideshow-card: unknown video_audio mode '${value}'`);
}
function normalizePhotoControls(value) {
if (value == null || value === false) return "off";
if (value === true) return "always";
@@ -66,8 +92,27 @@ const CAMERA_CAPTION_FIELDS = [
"camera_make", "camera_model", "focal_length_mm", "aperture_f_number",
"iso", "exposure_time_seconds",
];
const CAPTION_FIELDS = ["date", "location", "description", "camera", ...CAMERA_CAPTION_FIELDS, "current_date", "current_time", "weather"];
const CAPTION_FIELDS = [
"date", "location", "description", "camera", ...CAMERA_CAPTION_FIELDS,
"filename", "path", "video_duration", "video_version", "current_date", "current_time", "weather",
];
const LIVE_CAPTION_FIELDS = new Set(["current_date", "current_time", "weather"]);
/** ``1:50`` / ``1:02:03`` for a clip length in seconds; empty otherwise. */
function formatClipLength(seconds) {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "";
const total = Math.round(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = String(total % 60).padStart(2, "0");
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}`;
}
/** First corner that no caption or toolbar uses, for the video sound button. */
function soundButtonCorner(captionPositions, toolbarCorner) {
const taken = new Set([...captionPositions, toolbarCorner]);
return ["top-left", "top-right", "bottom-left", "bottom-right"].find((corner) => !taken.has(corner)) ?? "top-left";
}
const CAPTION_POSITIONS = new Set([
"top-left",
"top-center",
@@ -669,6 +714,8 @@ 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",
videos: config.videos !== false,
video_audio: normalizeVideoAudio(config.video_audio),
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
@@ -751,11 +798,16 @@ function createAlbumSlideshowCardClass(Base) {
}
this._captionResizeObserver ??= new ResizeObserver(() => this._fitCaptions());
this._captionResizeObserver.observe(this);
this._onVisibilityChange ??= () => this._syncViewerHeartbeat();
globalThis.document?.addEventListener?.("visibilitychange", this._onVisibilityChange);
this._maybeSwap();
this._syncViewerHeartbeat();
}
disconnectedCallback() {
this._captionResizeObserver?.disconnect();
globalThis.document?.removeEventListener?.("visibilitychange", this._onVisibilityChange);
this._syncViewerHeartbeat();
clearTimeout(this._captionClockTimer);
this._captionClockTimer = null;
this._cancelControlNavigation();
@@ -766,6 +818,7 @@ function createAlbumSlideshowCardClass(Base) {
this._loadGeneration += 1;
this._lastFrameId = null;
this._lastEntityPicture = null;
this._stopVideo();
}
set hass(hass) {
@@ -773,6 +826,7 @@ function createAlbumSlideshowCardClass(Base) {
this._hass = hass;
if (!this._rendered) return;
this._maybeSwap();
this._syncViewerHeartbeat();
if (this._captionData && this._captionConfigs().some((cap) => cap.show.includes("weather") && cap.weather_entity &&
(previous?.states?.[cap.weather_entity] !== hass.states?.[cap.weather_entity] || previous?.locale !== hass.locale ||
(cap.weather_display === "icon_temperature" &&
@@ -781,6 +835,26 @@ function createAlbumSlideshowCardClass(Base) {
}
}
/** Check in while this card is on a visible page and Pause when not viewed is on. */
_syncViewerHeartbeat() {
const attrs = this._hass?.states?.[this._config?.entity]?.attributes;
const entryId = attrs?.pause_when_unwatched ? attrs.entry_id : null;
if (!this.isConnected || globalThis.document?.visibilityState === "hidden" || !entryId) {
clearInterval(this._viewerTimer);
this._viewerTimer = null;
this._viewerEntryId = null;
return;
}
if (this._viewerTimer && this._viewerEntryId === entryId) return;
clearInterval(this._viewerTimer);
this._viewerEntryId = entryId;
// Errors (reconnecting, or an older integration) are retried next beat.
const beat = () => this._hass?.callWS?.({ type: "album_slideshow/viewer_heartbeat", entry_id: entryId })
?.catch?.(() => {});
beat();
this._viewerTimer = setInterval(beat, VIEWER_HEARTBEAT_MS);
}
_resolvedFit(attrs) {
// ``auto`` inherits from the camera's fill_mode attribute. The camera
// exposes cover / contain / blur. ``blur`` is rendered as ``contain``
@@ -815,6 +889,11 @@ function createAlbumSlideshowCardClass(Base) {
const stageBg = c.background
? c.background
: "var(--ha-card-background, var(--card-background-color, transparent))";
const toolbarEdge = c.caption?.position.startsWith("top") ? "bottom" : "top";
const [soundY, soundX] = soundButtonCorner(
c.captions.map((cap) => cap.position),
c.photo_controls === "off" ? null : `${toolbarEdge}-right`,
).split("-");
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
@@ -870,6 +949,35 @@ function createAlbumSlideshowCardClass(Base) {
.layer.fit-cover { object-fit: cover; }
.layer.fit-contain { object-fit: contain; }
.layer.show { opacity: 1; }
.video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity ${c.duration}ms ${c.easing};
pointer-events: none;
}
.video.fit-contain { object-fit: contain; }
.video.show { opacity: 1; }
.sound {
position: absolute;
${soundY}: 8px;
${soundX}: 8px;
z-index: 3;
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #fff;
cursor: pointer;
}
.sound[hidden] { display: none; }
.placeholder {
position: absolute;
inset: 0;
@@ -911,7 +1019,7 @@ function createAlbumSlideshowCardClass(Base) {
max-width: 100%; max-height: 100%; min-width: 0; overflow: hidden;
}
.cap-line { font-weight: inherit; flex-shrink: 0; font-variant-numeric: tabular-nums; }
#photo-controls { position: absolute; right: 8px; max-width: calc(100% - 16px); ${c.caption?.position.startsWith("top") ? "bottom" : "top"}: 8px; z-index: 3; }
#photo-controls { position: absolute; right: 8px; max-width: calc(100% - 16px); ${toolbarEdge}: 8px; z-index: 3; }
.cap-box.cap-shadow {
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.9),
@@ -939,10 +1047,12 @@ function createAlbumSlideshowCardClass(Base) {
<img class="blur-bg" id="blur-b" alt="" />
<img class="layer" id="a" alt="" />
<img class="layer" id="b" alt="" />
<video class="video" id="video" muted playsinline loop preload="auto" disablepictureinpicture aria-hidden="true"></video>
<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>
<button class="sound" id="sound" type="button" hidden aria-label="Unmute video"><ha-icon icon="mdi:volume-off"></ha-icon></button>
</ha-card>
`;
this._photoControls = new PhotoControls(
@@ -950,6 +1060,11 @@ function createAlbumSlideshowCardClass(Base) {
() => this._controlsReveal?.activity(),
(phase, entryId) => this._controlNavigation(phase, entryId),
);
this.shadowRoot.getElementById("sound").addEventListener("click", (event) => {
// Never let the speaker button open more-info or reveal controls.
event.stopPropagation();
this._toggleVideoSound();
});
const card = this.shadowRoot.querySelector("ha-card");
if (this._config.tap_action === "more-info") {
card.addEventListener("click", () => this._fireMoreInfo());
@@ -1077,6 +1192,7 @@ function createAlbumSlideshowCardClass(Base) {
}
this._hiddenRevision = attrs.hidden_revision;
this._refreshPhotoControls(attrs);
this._syncVideoPaused(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...");
@@ -1129,6 +1245,7 @@ function createAlbumSlideshowCardClass(Base) {
const captionData = this._config.caption
? {
caption_frames: attrs.caption_frames,
current_path: attrs.current_path,
pair_orientation: attrs.pair_orientation,
captured_at_primary: attrs.captured_at_primary,
captured_at: attrs.captured_at,
@@ -1146,6 +1263,12 @@ function createAlbumSlideshowCardClass(Base) {
frameId,
hiddenRevision: attrs.hidden_revision,
entityId: this._config.entity,
videoUrl:
this._config.videos !== false && ["video", "live_photo"].includes(attrs.media_kind) && attrs.video_url
? attrs.video_url
: null,
// A Live Photo's motion plays once, then fades back to its still.
videoLoop: attrs.media_kind !== "live_photo",
};
this._loadAndSwap(url, fit, blurBackdrop, captionData, photoData);
}
@@ -1167,6 +1290,7 @@ function createAlbumSlideshowCardClass(Base) {
image.removeAttribute("src");
image.classList.remove("show", "exit", "enter");
}
this._stopVideo();
this.shadowRoot.getElementById("captions").replaceChildren();
this._refreshPhotoControls(this._hass.states[this._config.entity]?.attributes || {});
}
@@ -1203,11 +1327,17 @@ function createAlbumSlideshowCardClass(Base) {
this._maybeSwap();
return;
}
// A re-render of the same photo (new metadata, e.g. a face-aware crop)
// should not look like a new slide.
const samePhoto = photoData.photoIds.length > 0 &&
photoData.photoIds.length === this._displayedPhotoIds.length &&
photoData.photoIds.every((id, index) => id === this._displayedPhotoIds[index]);
this._displayedPhotoIds = [...photoData.photoIds];
this._displayedFrameId = photoData.frameId;
this._photoEntryId = photoData.entryId;
this._photoOrientation = photoData.orientation;
this._performSwap(url, fit, blurBackdrop, captionData);
this._performSwap(url, fit, blurBackdrop, captionData, samePhoto);
this._startVideo(photoData.videoUrl, fit, generation, photoData.videoLoop !== false);
if (this._navigationRequest && !this._navigationRequest.pending && photoData.frameId !== this._navigationRequest.frameId) {
this._cancelControlNavigation();
}
@@ -1219,7 +1349,105 @@ function createAlbumSlideshowCardClass(Base) {
next.src = url;
}
_performSwap(url, fit, blurBackdrop, captionData) {
/** Play a video slide over its poster frame (already swapped in). The
* clip fades in once it is actually playing; if it can't load or the
* browser blocks autoplay, the poster simply stays up. */
_startVideo(videoUrl, fit, generation, loop = true) {
const video = this.shadowRoot?.getElementById("video");
if (!video) return;
// A re-render of the same clip (metadata refreshes do this) keeps the
// same URL; let it play on rather than restarting it.
const continuing = Boolean(videoUrl) && video.getAttribute("src") === videoUrl;
if (!continuing) this._stopVideo();
if (!videoUrl) return;
const attrs = this._hass?.states[this._config.entity]?.attributes || {};
const reveal = () => {
if (generation === this._loadGeneration) video.classList.add("show");
};
video.classList.toggle("fit-contain", fit === "contain");
video.onerror = () => video.classList.remove("show");
// Fade in once playing, or, while paused, once the first frame is ready.
video.onplaying = reveal;
video.onloadeddata = attrs.paused ? reveal : null;
video.onended = loop ? null : () => {
if (generation === this._loadGeneration) video.classList.remove("show");
};
video.onvolumechange = () => this._updateSoundButton();
if (continuing) return;
// Browsers only autoplay muted unless the page has been interacted
// with; ``_playVideo`` falls back to muted. Set the properties as well
// as the attributes, which some browsers read only at parse time.
video.muted = !this._wantsVideoSound();
video.loop = loop;
video.playsInline = true;
video.src = videoUrl;
this._videoPaused = Boolean(attrs.paused);
this._updateSoundButton();
if (!attrs.paused) this._playVideo(video);
}
/** Sound for the next video: the speaker button's last choice this
* session, else the configured mode. */
_wantsVideoSound() {
if (this._config.video_audio === "off") return false;
return this._videoSoundChoice ?? this._config.video_audio === "on";
}
/** Play, falling back to muted when the browser blocks sound. */
_playVideo(video) {
video.play()?.catch((err) => {
if (video.muted || err?.name !== "NotAllowedError") return;
video.muted = true;
this._updateSoundButton();
video.play()?.catch(() => {});
});
}
_toggleVideoSound() {
const video = this.shadowRoot?.getElementById("video");
if (!video || !video.getAttribute("src")) return;
// The tap itself lets the browser allow sound from now on.
video.muted = !video.muted;
this._videoSoundChoice = !video.muted;
if (!video.muted && video.paused && !this._videoPaused) this._playVideo(video);
this._updateSoundButton();
}
_updateSoundButton() {
const button = this.shadowRoot?.getElementById("sound");
if (!button) return;
const video = this.shadowRoot.getElementById("video");
const active = this._config.video_audio !== "off" && Boolean(video?.getAttribute("src"));
button.hidden = !active;
if (!active) return;
button.setAttribute("aria-label", video.muted ? "Unmute video" : "Mute video");
button.querySelector("ha-icon")?.setAttribute("icon", video.muted ? "mdi:volume-off" : "mdi:volume-high");
}
/** Stop any playing clip and drop its source so it stops downloading. */
_stopVideo() {
const video = this.shadowRoot?.getElementById("video");
if (!video || !video.getAttribute("src")) return;
video.classList.remove("show");
video.onplaying = video.onerror = video.onloadeddata = video.onended = video.onvolumechange = null;
video.pause();
video.removeAttribute("src");
video.load();
this._updateSoundButton();
}
/** Follow the slideshow's pause switch. Acts only when it changes, since
* ``hass`` updates arrive for every entity in Home Assistant. */
_syncVideoPaused(attrs) {
const video = this.shadowRoot?.getElementById("video");
const paused = Boolean(attrs.paused);
if (!video || !video.getAttribute("src") || paused === this._videoPaused) return;
this._videoPaused = paused;
if (paused) video.pause();
else this._playVideo(video);
}
_performSwap(url, fit, blurBackdrop, captionData, samePhoto = false) {
const root = this.shadowRoot;
const placeholder = root.getElementById("placeholder");
if (placeholder) placeholder.remove();
@@ -1239,7 +1467,7 @@ function createAlbumSlideshowCardClass(Base) {
el.classList.add(fit === "contain" ? "fit-contain" : "fit-cover");
}
const transition = this._pickTransition();
const transition = samePhoto ? "fade" : this._pickTransition();
const transitionClass = `t-${transition}`;
// First frame: no animation, just place the image and reveal.
@@ -1422,7 +1650,12 @@ function createAlbumSlideshowCardClass(Base) {
* the flat attributes for older integration versions. */
_buildCaptionFrames(data) {
const cf = data.caption_frames;
if (Array.isArray(cf) && cf.length) return cf;
if (Array.isArray(cf) && cf.length) {
// File paths come separately (kept out of the recorder): a string, or
// one per image for a pair.
const paths = Array.isArray(data.current_path) ? data.current_path : [data.current_path];
return cf.map((frame, index) => (paths[index] == null ? frame : { ...frame, path: paths[index] }));
}
let captured = data.captured_at_primary;
if (captured == null) {
captured = Array.isArray(data.captured_at)
@@ -1458,6 +1691,14 @@ function createAlbumSlideshowCardClass(Base) {
if (frame.location) lines.push(String(frame.location));
} else if (field === "description") {
if (frame.description) lines.push(String(frame.description));
} else if (field === "filename" || field === "path") {
if (typeof frame[field] === "string" && frame[field].trim()) lines.push(frame[field].trim());
} else if (field === "video_duration") {
const length = formatClipLength(frame.video_duration);
if (length) lines.push(length);
} else if (field === "video_version") {
const version = this._videoVersionCaption(frame);
if (version) lines.push(version);
} else if (field === "camera") {
const make = typeof frame.camera_make === "string" ? frame.camera_make.trim() : "";
const model = typeof frame.camera_model === "string" ? frame.camera_model.trim() : "";
@@ -1481,6 +1722,17 @@ function createAlbumSlideshowCardClass(Base) {
return lines;
}
/** ``Transcoded video · 3.1 Mbps`` for a video slide; empty for photos. */
_videoVersionCaption(frame) {
const version = { original: "Original video", transcoded: "Transcoded video" }[frame.video_version];
const bitrate = frame.video_bitrate_mbps;
const rate = typeof bitrate === "number" && Number.isFinite(bitrate) && bitrate > 0
? `${new Intl.NumberFormat(this._locale(), { maximumFractionDigits: 1 }).format(bitrate)} Mbps`
: "";
if (!version && !rate) return "";
return [version || "Video", rate].filter(Boolean).join(" · ");
}
_weatherCaption(cap) {
if (typeof cap.weather_entity !== "string" || !/^(weather|sensor)\.[a-z0-9_]+$/.test(cap.weather_entity)) return "";
const entity = this._hass?.states?.[cap.weather_entity];
@@ -1805,6 +2057,10 @@ const CAPTION_SHOW_OPTIONS = [
{ value: "aperture_f_number", label: "Aperture" },
{ value: "iso", label: "ISO" },
{ value: "exposure_time_seconds", label: "Exposure time" },
{ value: "filename", label: "File name" },
{ value: "path", label: "File path" },
{ value: "video_duration", label: "Video length" },
{ value: "video_version", label: "Video version (original or transcoded)" },
{ value: "current_date", label: "Today's date" },
{ value: "current_time", label: "Current time" },
{ value: "weather", label: "Weather" },
@@ -1890,6 +2146,7 @@ const CAPTION_DEFAULTS = {
// service call. Buttons are handled separately (see LIVE_ACTIONS).
const LIVE_FIELDS = [
"paused",
"pause_when_unwatched",
"date_filter",
"custom_lookback_days",
"shuffle_age_bias",
@@ -1904,6 +2161,7 @@ const LIVE_FIELDS = [
const LIVE_SUFFIX = {
paused: "_paused",
pause_when_unwatched: "_pause_when_unwatched",
date_filter: "_date_filter",
custom_lookback_days: "_custom_lookback_days",
shuffle_age_bias: "_shuffle_age_bias",
@@ -1921,6 +2179,7 @@ const LIVE_SUFFIX = {
const LIVE_LABELS = {
live_paused: "Pause slideshow",
live_pause_when_unwatched: "Pause when not viewed",
live_date_filter: "Date filter",
live_custom_lookback_days: "Custom lookback (days)",
live_shuffle_age_bias: "Shuffle age bias (older - / newer +)",
@@ -2080,8 +2339,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
_liveSchema() {
const s = this._siblings || {};
const items = [];
if (s.paused) {
items.push({ name: "live_paused", selector: { boolean: {} } });
for (const field of ["paused", "pause_when_unwatched"]) {
if (s[field]) items.push({ name: `live_${field}`, selector: { boolean: {} } });
}
for (const [field, id] of [
["date_filter", s.date_filter],
@@ -2221,6 +2480,11 @@ function createAlbumSlideshowCardEditorClass(Base) {
selector: { select: { mode: "dropdown", options: FIT_OPTIONS } },
},
{ name: "background", selector: { text: {} } },
{ name: "videos", selector: { boolean: {} } },
{
name: "video_audio",
selector: { select: { mode: "dropdown", options: VIDEO_AUDIO_OPTIONS } },
},
],
},
{
@@ -2277,6 +2541,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
fit: c.fit || DEFAULTS.fit,
background: c.background || "",
tap_action: c.tap_action || DEFAULTS.tap_action,
videos: c.videos !== false,
video_audio: normalizeVideoAudio(c.video_audio),
tap_pause_seconds:
c.tap_pause_seconds != null
? Number(c.tap_pause_seconds)
@@ -2634,9 +2900,11 @@ function createAlbumSlideshowCardEditorClass(Base) {
const out = {};
if (!s || !this._hass) return out;
const st = (id) => (id ? this._hass.states[id] : null);
if (s.paused) {
const e = st(s.paused);
out.live_paused = !!e && e.state === "on";
for (const f of ["paused", "pause_when_unwatched"]) {
if (s[f]) {
const e = st(s[f]);
out[`live_${f}`] = !!e && e.state === "on";
}
}
for (const f of ["date_filter", "missing_date_mode", "portrait_mode", "order_mode"]) {
if (s[f]) {
@@ -2668,6 +2936,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
background: "Background (optional)",
tap_action: "Tap action",
tap_pause_seconds: "Tap pause (seconds)",
videos: "Play videos",
video_audio: "Video sound",
photo_controls: "Photo controls",
caption_enabled: "Show caption overlay",
caption_show: "Show",
@@ -2693,6 +2963,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
const helpers = {
background: "Leave blank to inherit the dashboard theme.",
transition: "Random picks a different effect each slide.",
videos: "Videos and Live Photo motion. Off shows a still frame instead. Must also be enabled in the integration.",
video_audio: "Muted starts videos silent with a speaker button. On plays sound when the browser allows it, otherwise shows the button.",
tap_pause_seconds:
"How long the card freezes its slide after a tap. 0 disables it.",
caption_date_format:
@@ -2707,6 +2979,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
caption_font_size: "CSS size, e.g. 14px, 1.1em.",
live_paused:
"These control the Album Slideshow integration directly and apply everywhere this album is shown, not only this card.",
live_pause_when_unwatched:
"Hold the slideshow when no dashboard card or camera view has shown it for a minute; it resumes as soon as one opens.",
live_missing_date_mode:
"What a date filter does with photos that have no capture date: use the upload date, keep them, or drop them.",
};
@@ -2899,7 +3173,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
if (!s || !hass) return;
const id = s[field];
if (!id) return;
if (field === "paused") {
if (field === "paused" || field === "pause_when_unwatched") {
hass.callService("switch", value ? "turn_on" : "turn_off", {
entity_id: id,
});
@@ -2982,6 +3256,10 @@ function createAlbumSlideshowCardEditorClass(Base) {
const ta = data.tap_action || DEFAULTS.tap_action;
if (ta !== DEFAULTS.tap_action) n.tap_action = ta;
if (data.videos === false) n.videos = false;
const audio = normalizeVideoAudio(data.video_audio);
if (audio !== "off") n.video_audio = audio;
const tps = Number(data.tap_pause_seconds);
if (!isNaN(tps) && tps !== DEFAULTS.tap_pause_seconds) {
n.tap_pause_seconds = tps;