251 files

This commit is contained in:
Home Assistant Version Control committed 2026-10-16 12:38:25 +00:00
1 parent 3acf0cdf8d
commit 64eb0c647c
251 files changed
+6380 -14560

No files matched your search

@@ -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;