59 files
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user