This commit is contained in:
Home Assistant Version Control
2026-09-25 04:10:30 +00:00
parent 56c80f896e
commit ad634c1b84
60 changed files with 2895 additions and 794 deletions
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.10.0";
const VERSION = "1.11.0";
const ANIMATED_TRANSITIONS = [
"fade",
@@ -46,6 +46,19 @@ const TRANSITIONS = new Set(["random", "none", ...ANIMATED_TRANSITIONS]);
const FIT_MODES = new Set(["auto", "cover", "contain"]);
const PHOTO_CONTROL_OPTIONS = [
{ value: "off", label: "Off" },
{ value: "on_demand", label: "On demand" },
{ value: "always", label: "Always" },
];
function normalizePhotoControls(value) {
if (value == null || value === false) return "off";
if (value === true) return "always";
if (PHOTO_CONTROL_OPTIONS.some((option) => option.value === value)) return value;
throw new Error(`album-slideshow-card: unknown photo controls mode '${value}'`);
}
// Caption overlay (date / location / description). ``show`` is an ordered
// subset of these fields; ``position`` is one of a 3x3 anchor grid;
// ``date_format`` is one of the named presets below or a custom token string.
@@ -92,6 +105,436 @@ function isAlbumSlideshowCamera(state) {
);
}
class PhotoControls {
constructor(container, getHass, onActivity = () => {}, onNavigate = () => {}) {
this._getHass = getHass;
this._onActivity = onActivity;
this._onNavigate = onNavigate;
this._state = {};
this._busy = false;
this._root = container.attachShadow({ mode: "open" });
this._root.innerHTML = `
<style>
:host { display: block; max-width: 100%; font-family: var(--paper-font-body1_-_font-family, sans-serif); }
:host([hidden]) { display: none !important; }
button { font: inherit; color: inherit; cursor: pointer; border: 0; border-radius: 6px; background: var(--secondary-background-color, #eee); padding: 8px 12px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; }
button:hover { filter: brightness(.94); }
button:disabled { opacity: .4; cursor: default; }
button:focus-visible { outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; }
.toolbar { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); width: 300px; max-width: 100%; box-sizing: border-box; gap: 4px 12px; padding: 4px; border-radius: 6px; background: var(--card-background-color, #fff); color: var(--primary-text-color, #222); }
.control-group { display: flex; gap: 4px; justify-content: center; }
.icon { width: 44px; height: 44px; padding: 10px; flex: 0 0 44px; }
ha-icon { --mdc-icon-size: 22px; }
dialog { box-sizing: border-box; width: min(460px, calc(100vw - 32px)); max-width: calc(100vw - 32px); max-height: min(640px, calc(100dvh - 40px)); border: 1px solid var(--divider-color, #ddd); border-radius: 8px; padding: 16px; font-size: 14px; background: var(--card-background-color, #fff); color: var(--primary-text-color, #222); }
dialog::backdrop { background: rgba(0, 0, 0, .45); }
header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
h3 { margin: 0; font-size: 18px; letter-spacing: 0; }
.choices { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--divider-color, #ddd); }
.name { min-width: 0; overflow-wrap: anywhere; }
small { display: block; color: var(--secondary-text-color, #666); margin-top: 4px; }
footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.pagination { display: flex; gap: 8px; align-items: center; font-size: 13px; }
.error { margin: 8px 0; max-width: 320px; padding: 8px; background: var(--card-background-color, #fff); color: var(--error-color, #b71c1c); overflow-wrap: anywhere; }
[hidden] { display: none !important; }
</style>
<div class="toolbar">
<div class="control-group" role="group" aria-label="Slideshow navigation">
<button type="button" class="icon" id="previous" title="Previous slide" aria-label="Previous slide"><ha-icon icon="mdi:skip-previous"></ha-icon></button>
<button type="button" class="icon" id="pause" title="Pause slideshow" aria-label="Pause slideshow"><ha-icon icon="mdi:pause"></ha-icon></button>
<button type="button" class="icon" id="next" title="Next slide" aria-label="Next slide"><ha-icon icon="mdi:skip-next"></ha-icon></button>
</div>
<div class="control-group" role="group" aria-label="Photo visibility">
<button type="button" class="icon" id="hide" title="Hide photo" aria-label="Hide photo"><ha-icon icon="mdi:eye-off"></ha-icon></button>
<button type="button" class="icon" id="undo" title="Undo hide" aria-label="Undo hide"><ha-icon icon="mdi:undo"></ha-icon></button>
<button type="button" class="icon" id="manage" title="Hidden photos" aria-label="Hidden photos"><ha-icon icon="mdi:image-off-outline"></ha-icon></button>
</div>
</div>
<div id="error" class="error" role="alert" hidden></div>
<dialog aria-labelledby="title">
<header><h3 id="title">Hidden photos</h3><button type="button" class="icon" id="close" title="Close" aria-label="Close"><ha-icon icon="mdi:close"></ha-icon></button></header>
<div id="dialog-error" class="error" role="alert" hidden></div>
<div id="content"></div>
</dialog>
`;
this._dialog = this._root.querySelector("dialog");
this._dialog.addEventListener("close", () => this._onActivity());
container.addEventListener("click", (event) => event.stopPropagation());
this._root.getElementById("previous").addEventListener("click", () => this._navigate("previous_slide"));
this._root.getElementById("next").addEventListener("click", () => this._navigate("next_slide"));
this._root.getElementById("pause").addEventListener("click", () => this._togglePause());
this._root.getElementById("hide").addEventListener("click", () => this._hide());
this._root.getElementById("undo").addEventListener("click", () => this._run("undo_hide"));
this._root.getElementById("manage").addEventListener("click", () => this._showHidden(0));
this._root.getElementById("close").addEventListener("click", () => this._dialog.close());
}
get active() {
return this._busy || this._dialog.open;
}
close() {
this._dialog.close();
}
update(state) {
if (this._state.entryId && this._state.entryId !== state.entryId) {
this._dialog.close();
}
this._state = { ...state, photoIds: [...(state.photoIds || [])] };
const unavailable = this._busy || !state.entryId;
this._root.getElementById("previous").disabled = unavailable || !state.canPrevious;
this._root.getElementById("next").disabled = unavailable || !state.canNext;
const pause = this._root.getElementById("pause");
pause.disabled = unavailable || typeof state.paused !== "boolean";
pause.title = state.paused ? "Resume slideshow" : "Pause slideshow";
pause.setAttribute("aria-label", pause.title);
pause.querySelector("ha-icon").setAttribute("icon", state.paused ? "mdi:play" : "mdi:pause");
this._root.getElementById("hide").disabled = this._busy || !state.entryId || !this._state.photoIds.some(Boolean);
this._root.getElementById("undo").disabled = this._busy || !state.entryId || !state.canUndo;
this._root.getElementById("manage").disabled = this._busy || !state.entryId;
this._root.getElementById("manage").title = `Hidden photos (${state.hiddenCount || 0})`;
}
async _call(service, data = {}, entryId = this._state.entryId) {
if (!entryId) throw new Error("This slideshow does not support these controls");
const result = await this._getHass().callWS({
type: "call_service",
domain: "album_slideshow",
service,
service_data: { ...data, entry_id: entryId },
...(service === "list_hidden_photos" ? { return_response: true } : {}),
});
return result?.response;
}
async _navigate(service) {
if (this._busy || !this._state.entryId) return false;
const entryId = this._state.entryId;
this._onNavigate?.("start", entryId);
const success = await this._run(service, {}, entryId);
this._onNavigate?.(success ? "complete" : "failed", entryId);
return success;
}
async _togglePause() {
const { entryId, paused } = this._state;
if (!entryId || typeof paused !== "boolean") return false;
return this._runAction(async () => {
const hass = this._getHass();
const registry = await hass.callWS({ type: "config/entity_registry/list" });
const entity = registry.find((candidate) =>
candidate.config_entry_id === entryId &&
candidate.unique_id === `${entryId}_paused` &&
candidate.entity_id.startsWith("switch.") &&
!candidate.disabled_by,
);
if (!entity) throw new Error("The pause switch is unavailable for this slideshow");
await hass.callWS({
type: "call_service",
domain: "switch",
service: paused ? "turn_off" : "turn_on",
service_data: { entity_id: entity.entity_id },
});
});
}
_error(message = "") {
for (const id of ["error", "dialog-error"]) {
const element = this._root.getElementById(id);
element.textContent = message;
element.hidden = !message;
}
}
async _run(service, data = {}, entryId = this._state.entryId) {
return this._runAction(() => this._call(service, data, entryId));
}
async _runAction(action) {
if (this._busy) return false;
this._busy = true;
this._onActivity?.();
this._error();
this.update(this._state);
const buttons = [...this._dialog.querySelectorAll("#content button")].map((button) => ({ button, disabled: button.disabled }));
buttons.forEach(({ button }) => { button.disabled = true; });
try {
await action();
this._dialog.close();
return true;
} catch (error) {
this._error(error.message || "Photo action failed");
return false;
} finally {
this._busy = false;
this.update(this._state);
buttons.forEach(({ button, disabled }) => { button.disabled = disabled; });
this._onActivity?.();
}
}
_button(label, icon, action, iconOnly = false) {
const button = document.createElement("button");
button.type = "button";
button.title = label;
button.setAttribute("aria-label", label);
if (iconOnly) button.className = "icon";
const symbol = document.createElement("ha-icon");
symbol.setAttribute("icon", icon);
button.appendChild(symbol);
if (!iconOnly) button.appendChild(document.createTextNode(label));
button.addEventListener("click", action);
return button;
}
_open(title) {
this._viewGeneration = (this._viewGeneration || 0) + 1;
this._error();
this._root.getElementById("title").textContent = title;
const content = this._root.getElementById("content");
content.replaceChildren();
if (!this._dialog.open) this._dialog.showModal();
this._onActivity?.();
return content;
}
_hide() {
const { photoIds, orientation, entryId } = this._state;
if (photoIds.length === 1 && photoIds[0]) {
this._run("hide_photo", { photo_ids: [...photoIds] }, entryId);
return;
}
const content = this._open("Hide photo");
content.className = "choices";
const labels = orientation === "vertical" ? ["Hide top photo", "Hide bottom photo"] : ["Hide left photo", "Hide right photo"];
photoIds.forEach((photoId, index) => {
const button = this._button(labels[index], "mdi:eye-off", () => this._run("hide_photo", { photo_ids: [photoId] }, entryId));
button.disabled = !photoId;
content.appendChild(button);
});
const both = this._button("Hide both photos", "mdi:eye-off", () => this._run("hide_photo", { photo_ids: [...photoIds] }, entryId));
both.disabled = photoIds.length !== 2 || photoIds.some((photoId) => !photoId);
content.appendChild(both);
}
async _showHidden(offset) {
const entryId = this._state.entryId;
const content = this._open("Hidden photos");
const generation = this._viewGeneration;
content.className = "";
content.textContent = "Loading...";
try {
const page = await this._call("list_hidden_photos", { offset, limit: 25 }, entryId);
if (generation !== this._viewGeneration || this._state.entryId !== entryId || !this._dialog.open) return;
if (offset && !page.photos.length) {
await this._showHidden(Math.max(0, offset - 25));
return;
}
content.replaceChildren();
if (!page.photos.length) content.textContent = "No hidden photos";
for (const photo of page.photos) {
const row = document.createElement("div");
row.className = "row";
const name = document.createElement("span");
name.className = "name";
name.textContent = photo.name;
if (!photo.in_album) {
const status = document.createElement("small");
status.textContent = "Not in the current album";
name.appendChild(status);
}
row.append(name, this._button(`Restore ${photo.name}`, "mdi:restore", async () => {
if (await this._run("restore_photos", { photo_ids: [photo.photo_id] }, entryId)) await this._showHidden(offset);
}, true));
content.appendChild(row);
}
if (!page.total) return;
const footer = document.createElement("footer");
footer.appendChild(this._button("Restore all", "mdi:restore", () => {
const confirm = this._open("Restore all hidden photos?");
confirm.className = "choices";
confirm.append(
this._button("Restore all", "mdi:restore", async () => {
if (await this._run("restore_all_photos", {}, entryId)) await this._showHidden(0);
}),
this._button("Cancel", "mdi:close", () => this._showHidden(offset)),
);
}));
const pagination = document.createElement("div");
pagination.className = "pagination";
const previous = this._button("Previous page", "mdi:chevron-left", () => this._showHidden(Math.max(0, offset - 25)), true);
const next = this._button("Next page", "mdi:chevron-right", () => this._showHidden(offset + 25), true);
previous.disabled = offset === 0;
next.disabled = offset + page.photos.length >= page.total;
pagination.append(previous, document.createTextNode(`${offset + 1}-${offset + page.photos.length} of ${page.total}`), next);
footer.appendChild(pagination);
content.appendChild(footer);
} catch (error) {
if (generation !== this._viewGeneration || this._state.entryId !== entryId) return;
content.textContent = "";
this._error(error.message || "Could not load hidden photos");
}
}
}
class PhotoControlsReveal {
constructor(card, container, controls, resume) {
this._card = card;
this._container = container;
this._controls = controls;
this._resume = resume;
this._visible = false;
this._pointer = null;
this._listeners = [];
container.hidden = true;
this._listen(card, "pointerenter", (event) => {
if (event.pointerType === "mouse") this.show();
});
this._listen(card, "pointerleave", (event) => {
this._cancelPress();
if (event.pointerType === "mouse" && !this._keyboardFocused()) this.hide();
else this.activity();
});
this._listen(card, "pointerdown", (event) => this._pointerDown(event));
this._listen(card, "pointermove", (event) => this._pointerMove(event));
this._listen(card, "pointerup", () => { this._cancelPress(); this.activity(); });
this._listen(card, "pointercancel", () => { this._cancelPress(); this.activity(); });
this._listen(card, "click", (event) => {
if (event.composedPath().includes(container)) {
this._suppressClick = false;
this.activity();
} else if (this._suppressClick && event.detail !== 0) {
this._suppressClick = false;
event.preventDefault();
event.stopImmediatePropagation();
}
}, true);
this._listen(card, "contextmenu", (event) => {
if (this._suppressClick || (this._pointer && this._pointer.type !== "mouse")) {
event.preventDefault();
event.stopPropagation();
this._suppressClick = true;
this.show();
}
});
this._listen(card, "focusin", (event) => {
if (!this._ignoreFocus && event.composedPath()[0]?.matches?.(":focus-visible")) this.show();
else this.activity();
});
this._listen(card, "focusout", () => this.activity());
this._listen(card, "keydown", (event) => this._keyDown(event));
this._listen(card.ownerDocument, "pointerdown", (event) => {
if (!event.composedPath().includes(card)) this.hide();
}, true);
}
get holding() {
return !this._disposed && (this._visible || this._pointer !== null);
}
_listen(target, type, listener, capture = false) {
target.addEventListener(type, listener, capture);
this._listeners.push(() => target.removeEventListener(type, listener, capture));
}
show() {
if (this._disposed) return;
this._visible = true;
this._container.hidden = false;
this.activity();
}
hide() {
if (this._disposed || this._controls.active) return;
const wasHolding = this.holding;
this._visible = false;
this._container.hidden = true;
clearTimeout(this._idleTimer);
this._idleTimer = null;
this._cancelPress(false);
if (wasHolding) this._resume();
}
activity() {
if (this._disposed || !this._visible) return;
clearTimeout(this._idleTimer);
this._idleTimer = setTimeout(() => {
this._idleTimer = null;
if (this._controls.active || this._pointer || this._keyboardFocused()) return;
this.hide();
}, 5000);
}
_keyboardFocused() {
return this._card.matches(":focus-visible") || Boolean(this._container.shadowRoot?.querySelector(":focus-visible"));
}
_pointerDown(event) {
if (event.composedPath().includes(this._container)) {
this.activity();
return;
}
this._suppressClick = false;
if (event.isPrimary === false || event.button > 0) {
this._cancelPress();
return;
}
this._cancelPress(false);
this._pointer = { id: event.pointerId, type: event.pointerType, x: event.clientX, y: event.clientY };
this._pressTimer = setTimeout(() => {
this._pressTimer = null;
if (!this._pointer || this._disposed) return;
this._suppressClick = true;
this.show();
}, 500);
}
_pointerMove(event) {
if (this._pointer) {
if (event.pointerId === this._pointer.id && Math.hypot(event.clientX - this._pointer.x, event.clientY - this._pointer.y) > 10) this._cancelPress();
} else if (event.pointerType === "mouse") {
this.show();
}
}
_cancelPress(resume = true) {
const wasPressed = this._pointer !== null;
clearTimeout(this._pressTimer);
this._pressTimer = null;
this._pointer = null;
if (resume && wasPressed && !this._visible && !this._disposed) this._resume();
}
_keyDown(event) {
if (event.key === "Escape" && this._visible && !this._controls.active) {
event.preventDefault();
event.stopPropagation();
this.hide();
this._ignoreFocus = true;
this._card.focus({ preventScroll: true });
this._ignoreFocus = false;
} else if ((event.key === "Enter" || event.key === " ") && event.composedPath()[0] === this._card) {
event.preventDefault();
event.stopPropagation();
this.show();
this._container.shadowRoot?.querySelector("button:not([disabled])")?.focus();
} else {
this.activity();
}
}
dispose() {
this._disposed = true;
clearTimeout(this._idleTimer);
this._cancelPress(false);
this._listeners.forEach((remove) => remove());
this._listeners = [];
this._controls.close();
this._visible = false;
}
}
// The card class is built lazily by a factory so the base class can be
// resolved from the *live* ``window.HTMLElement`` at registration time.
// See ``defineAlbumSlideshowCards`` for why this matters with the
@@ -135,6 +578,13 @@ function createAlbumSlideshowCardClass(Base) {
// schedules a deferred swap that runs once the hold expires.
this._holdSwapsUntil = 0;
this._holdSwapTimer = null;
this._loadGeneration = 0;
this._displayedPhotoIds = [];
this._displayedFrameId = null;
this._navigationRequest = null;
this._navigationTimer = null;
this._photoEntryId = null;
this._hiddenRevision = undefined;
}
setConfig(config) {
@@ -164,6 +614,7 @@ function createAlbumSlideshowCardClass(Base) {
// Empty/missing background means inherit theme.
background: typeof config.background === "string" ? config.background : "",
tap_action: config.tap_action === "more-info" ? "more-info" : "none",
photo_controls: normalizePhotoControls(config.photo_controls),
// Number of seconds the card freezes its visible slide after a
// tap, so the more-info dialog can settle without the slideshow
// marching forward beneath it. Set to 0 to disable.
@@ -232,7 +683,21 @@ function createAlbumSlideshowCardClass(Base) {
if (!this._rendered) {
this._renderShell();
this._rendered = true;
} else {
this._setupPhotoControlsReveal();
}
this._maybeSwap();
}
disconnectedCallback() {
this._cancelControlNavigation();
this._controlsReveal?.dispose();
this._controlsReveal = null;
clearTimeout(this._holdSwapTimer);
this._holdSwapTimer = null;
this._loadGeneration += 1;
this._lastFrameId = null;
this._lastEntityPicture = null;
}
set hass(hass) {
@@ -257,6 +722,14 @@ function createAlbumSlideshowCardClass(Base) {
_renderShell() {
const c = this._config;
this._cancelControlNavigation();
this._controlsReveal?.dispose();
this._controlsReveal = null;
this._loadGeneration += 1;
this._displayedPhotoIds = [];
this._displayedFrameId = null;
this._photoEntryId = null;
this._hiddenRevision = undefined;
const aspect = c.aspect_ratio === "auto" ? "auto" : c.aspect_ratio;
// When the user did not set ``background`` we fall through to the
// theme's --ha-card-background, so the card naturally inherits the
@@ -275,6 +748,7 @@ function createAlbumSlideshowCardClass(Base) {
position: relative;
padding: 0;
}
ha-card:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; }
.stage {
position: absolute;
inset: 0;
@@ -283,6 +757,7 @@ function createAlbumSlideshowCardClass(Base) {
background: ${stageBg};
border-radius: inherit;
overflow: hidden;
${c.photo_controls === "on_demand" ? "-webkit-touch-callout: none; user-select: none;" : ""}
}
.blur-bg {
position: absolute;
@@ -347,6 +822,7 @@ function createAlbumSlideshowCardClass(Base) {
font-family: var(--paper-font-body1_-_font-family, sans-serif);
}
.cap-line { font-weight: inherit; }
#photo-controls { position: absolute; right: 8px; max-width: calc(100% - 16px); ${c.caption?.position.startsWith("top") ? "bottom" : "top"}: 8px; z-index: 3; }
.cap-box.cap-shadow {
text-shadow:
0 1px 2px rgba(0, 0, 0, 0.9),
@@ -363,13 +839,66 @@ function createAlbumSlideshowCardClass(Base) {
<div class="captions" id="captions" aria-hidden="true"></div>
<div class="placeholder" id="placeholder">Waiting for first frame...</div>
</div>
<div id="photo-controls" ${c.photo_controls === "always" ? "" : "hidden"}></div>
</ha-card>
`;
this._photoControls = new PhotoControls(
this.shadowRoot.getElementById("photo-controls"), () => this._hass,
() => this._controlsReveal?.activity(),
(phase, entryId) => this._controlNavigation(phase, entryId),
);
const card = this.shadowRoot.querySelector("ha-card");
if (this._config.tap_action === "more-info") {
card.addEventListener("click", () => this._fireMoreInfo());
card.style.cursor = "pointer";
}
this._setupPhotoControlsReveal();
}
_setupPhotoControlsReveal() {
this._controlsReveal?.dispose();
this._controlsReveal = null;
const card = this.shadowRoot.querySelector("ha-card");
const container = this.shadowRoot.getElementById("photo-controls");
container.hidden = this._config.photo_controls !== "always";
if (this._config.photo_controls === "on_demand") {
card.tabIndex = 0;
card.setAttribute("role", "group");
card.setAttribute("aria-label", "Slideshow photo controls");
if (this.isConnected) {
this._controlsReveal = new PhotoControlsReveal(card, container, this._photoControls, () => this._maybeSwap());
}
}
}
_cancelControlNavigation() {
clearTimeout(this._navigationTimer);
this._navigationTimer = null;
this._navigationRequest = null;
}
_controlNavigation(phase, entryId) {
const attrs = this._hass?.states[this._config.entity]?.attributes || {};
if (attrs.entry_id !== entryId) return;
if (phase === "start") {
this._cancelControlNavigation();
this._navigationRequest = { entryId, frameId: attrs.frame_id, pending: true };
this._holdSwapsUntil = 0;
clearTimeout(this._holdSwapTimer);
this._holdSwapTimer = null;
} else if (this._navigationRequest?.entryId === entryId) {
if (phase === "failed") {
this._cancelControlNavigation();
} else {
this._navigationRequest.pending = false;
if (this._displayedFrameId !== this._navigationRequest.frameId && this._displayedFrameId === attrs.frame_id) {
this._cancelControlNavigation();
} else {
this._navigationTimer = setTimeout(() => this._cancelControlNavigation(), 5000);
}
}
}
this._maybeSwap();
}
_transitionStyles() {
@@ -439,6 +968,18 @@ function createAlbumSlideshowCardClass(Base) {
this._setPlaceholder(`Entity not found: ${this._config.entity}`);
return;
}
const attrs = state.attributes || {};
if (this._hiddenRevision !== undefined && attrs.hidden_revision !== this._hiddenRevision) {
this._clearDisplayedPhotos();
}
this._hiddenRevision = attrs.hidden_revision;
this._refreshPhotoControls(attrs);
if (attrs.empty_reason || (Array.isArray(attrs.displayed_photo_ids) && !attrs.displayed_photo_ids.length)) {
this._clearDisplayedPhotos();
this._setPlaceholder(attrs.empty_reason === "all_hidden" ? "All photos hidden" : attrs.empty_reason ? "No matching photos" : "Preparing next photo...");
return;
}
if (this._controlsReveal?.holding && this._displayedPhotoIds.length && !this._navigationRequest) return;
// Hold visual swaps for the configured grace period after a tap.
// The state cursor (`_lastFrameId`/`_lastEntityPicture`) is left
// untouched during the hold; once the hold expires we re-enter
@@ -454,7 +995,6 @@ function createAlbumSlideshowCardClass(Base) {
}
return;
}
const attrs = state.attributes || {};
// ``frame_id`` increments on every slide commit; that's our primary
// "new frame ready" signal. The integration also embeds frame_id in
// ``entity_picture`` so that HA core surfaces (more-info, picture
@@ -495,15 +1035,79 @@ function createAlbumSlideshowCardClass(Base) {
description: attrs.description,
}
: null;
this._loadAndSwap(url, fit, blurBackdrop, captionData);
const photoData = {
photoIds: [...(attrs.displayed_photo_ids || [])],
entryId: attrs.entry_id,
orientation: attrs.pair_orientation,
frameId,
hiddenRevision: attrs.hidden_revision,
entityId: this._config.entity,
};
this._loadAndSwap(url, fit, blurBackdrop, captionData, photoData);
}
_loadAndSwap(url, fit, blurBackdrop, captionData) {
_clearDisplayedPhotos() {
this._loadGeneration += 1;
this._displayedPhotoIds = [];
this._displayedFrameId = null;
this._lastFrameId = null;
this._lastEntityPicture = null;
this._holdSwapsUntil = 0;
if (this._holdSwapTimer) clearTimeout(this._holdSwapTimer);
this._holdSwapTimer = null;
for (const id of ["a", "b", "blur-a", "blur-b"]) {
const image = this.shadowRoot.getElementById(id);
image.removeAttribute("src");
image.classList.remove("show", "exit", "enter");
}
this.shadowRoot.getElementById("captions").replaceChildren();
this._refreshPhotoControls(this._hass.states[this._config.entity]?.attributes || {});
}
_refreshPhotoControls(attrs) {
this._photoControls?.update({
entryId: attrs.entry_id,
photoIds: this._displayedPhotoIds,
orientation: this._photoOrientation,
hiddenCount: attrs.hidden_photo_count,
canUndo: attrs.undo_hide_available,
canPrevious: attrs.previous_frames_cached > 0,
canNext: attrs.media_count > 0,
paused: attrs.paused,
});
}
_loadAndSwap(url, fit, blurBackdrop, captionData, photoData) {
const generation = ++this._loadGeneration;
// Pre-decode the new image so the swap is instant.
const next = new Image();
next.decoding = "async";
next.onload = () => this._performSwap(url, fit, blurBackdrop, captionData);
next.onerror = () => this._setPlaceholder("Failed to load slide");
next.onload = () => {
if (generation !== this._loadGeneration) return;
if (this._controlsReveal?.holding && this._displayedPhotoIds.length && !this._navigationRequest) {
this._lastFrameId = null;
this._lastEntityPicture = null;
return;
}
const attrs = this._hass.states[this._config.entity]?.attributes || {};
if (photoData.entityId !== this._config.entity || (attrs.frame_id ?? null) !== photoData.frameId || attrs.hidden_revision !== photoData.hiddenRevision) {
this._lastFrameId = null;
this._maybeSwap();
return;
}
this._displayedPhotoIds = [...photoData.photoIds];
this._displayedFrameId = photoData.frameId;
this._photoEntryId = photoData.entryId;
this._photoOrientation = photoData.orientation;
this._performSwap(url, fit, blurBackdrop, captionData);
if (this._navigationRequest && !this._navigationRequest.pending && photoData.frameId !== this._navigationRequest.frameId) {
this._cancelControlNavigation();
}
this._refreshPhotoControls(attrs);
};
next.onerror = () => {
if (generation === this._loadGeneration) this._setPlaceholder("Failed to load slide");
};
next.src = url;
}
@@ -934,6 +1538,7 @@ const CAPTION_DATE_FORMAT_OPTIONS = [
];
const DEFAULTS = {
photo_controls: "off",
transition: "random",
duration: 600,
easing: "ease-in-out",
@@ -1036,6 +1641,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
// see entity state updates.
const form = this.shadowRoot.querySelector("ha-form");
if (form) form.hass = hass;
this._updatePhotoControls();
// Re-run a full update when the camera set changes (warning box) or
// when any surfaced integration entity changed state, so the live
// controls stay in sync with the integration.
@@ -1277,6 +1883,10 @@ function createAlbumSlideshowCardEditorClass(Base) {
title: "Interaction",
icon: "mdi:gesture-tap",
schema: [
{
name: "photo_controls",
selector: { select: { mode: "dropdown", options: PHOTO_CONTROL_OPTIONS } },
},
{
name: "tap_action",
selector: { select: { mode: "dropdown", options: TAP_OPTIONS } },
@@ -1364,6 +1974,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
const c = this._config || {};
return {
entity: c.entity || "",
photo_controls: normalizePhotoControls(c.photo_controls),
transition: c.transition || DEFAULTS.transition,
duration: c.duration != null ? Number(c.duration) : DEFAULTS.duration,
easing: c.easing || DEFAULTS.easing,
@@ -1441,6 +2052,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
background: "Background (optional)",
tap_action: "Tap action",
tap_pause_seconds: "Tap pause (seconds)",
photo_controls: "Photo controls",
caption_enabled: "Show caption overlay",
caption_show: "Show",
caption_position: "Position",
@@ -1517,12 +2129,16 @@ function createAlbumSlideshowCardEditorClass(Base) {
<div class="info-slot"></div>
<ha-form></ha-form>
<div class="actions" hidden></div>
<div class="photo-controls"></div>
</div>
`;
const form = this.shadowRoot.querySelector("ha-form");
form.computeLabel = this._computeLabel;
form.computeHelper = this._computeHelper;
form.addEventListener("value-changed", (ev) => this._valueChanged(ev));
this._photoControls = new PhotoControls(
this.shadowRoot.querySelector(".photo-controls"), () => this._hass,
);
this._rendered = true;
this._update();
}
@@ -1553,12 +2169,30 @@ function createAlbumSlideshowCardEditorClass(Base) {
}
this._renderActions();
this._updatePhotoControls();
}
_updatePhotoControls() {
if (!this._photoControls) return;
const attrs = this._hass?.states[this._config?.entity]?.attributes || {};
this.shadowRoot.querySelector(".photo-controls").hidden = !attrs.entry_id;
this._photoControls.update({
entryId: attrs.entry_id,
photoIds: attrs.displayed_photo_ids,
orientation: attrs.pair_orientation,
hiddenCount: attrs.hidden_photo_count,
canUndo: attrs.undo_hide_available,
canPrevious: attrs.previous_frames_cached > 0,
canNext: attrs.media_count > 0,
paused: attrs.paused,
});
}
_renderActions() {
const wrap = this.shadowRoot.querySelector(".actions");
if (!wrap) return;
if (!this._hasActions()) {
const hasToolbarNavigation = !!this._hass?.states[this._config?.entity]?.attributes?.entry_id;
if (!this._hasActions() || (hasToolbarNavigation && !this._siblings.refresh_button)) {
wrap.hidden = true;
wrap.innerHTML = "";
return;
@@ -1568,8 +2202,8 @@ function createAlbumSlideshowCardEditorClass(Base) {
wrap.innerHTML = `
<div class="actions-title">Actions</div>
<div class="actions-row">
${s.previous_button ? `<button class="act" data-act="previous">Previous slide</button>` : ""}
${s.next_button ? `<button class="act" data-act="next">Next slide</button>` : ""}
${s.previous_button && !hasToolbarNavigation ? `<button class="act" data-act="previous">Previous slide</button>` : ""}
${s.next_button && !hasToolbarNavigation ? `<button class="act" data-act="next">Next slide</button>` : ""}
${s.refresh_button ? `<button class="act" data-act="refresh">Refresh album</button>` : ""}
</div>
`;
@@ -1651,6 +2285,9 @@ function createAlbumSlideshowCardEditorClass(Base) {
const n = { type: "custom:album-slideshow-card" };
const photoControls = normalizePhotoControls(data.photo_controls);
if (photoControls === "always") n.photo_controls = true;
if (photoControls === "on_demand") n.photo_controls = "on_demand";
if (data.entity) n.entity = data.entity;
const t = data.transition || DEFAULTS.transition;