Dashboard Studio 1.8.0: vacuum card, clock position and background, light groups
- Vacuum card: status, battery, start/pause, dock, locate, suction power and an optional live map from a camera or image entity - Clock: 3x3 text position picker and a background image from links or Immich albums (shared slideshow code with the Photos card), dimmable - Light card lists the lights of a light group with their own switches; picking a group grows the card to fit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,9 @@ Bins use the integration's own colors (GFT green, Papier blue, PMD maroon, and s
|
|||||||
|
|
||||||
Pick a weather entity in the clock's settings to show the current conditions and temperature under the time. Today's high and low come from Home Assistant's forecast, refreshed every half hour. Tap the weather to see all its details.
|
Pick a weather entity in the clock's settings to show the current conditions and temperature under the time. Today's high and low come from Home Assistant's forecast, refreshed every half hour. Tap the weather to see all its details.
|
||||||
|
|
||||||
|
- **Text position:** place the time anywhere in the card with the 3×3 grid: top left, top, top right, left, center, right, bottom left, bottom or bottom right.
|
||||||
|
- **Background image:** choose image links (one per line) or Immich albums. With more than one photo the clock fades between them; set how often. **Darken the image** keeps the time readable on bright photos (30% by default), and the text turns white with a soft shadow.
|
||||||
|
|
||||||
### Horizontal scroll and swipe cards
|
### Horizontal scroll and swipe cards
|
||||||
|
|
||||||
These cards hold other cards. Select one in edit mode, pick a card type under **Cards** and press **Add**. The new card opens straight away so you can set it up; **←** takes you back. Use the arrows to reorder cards, and open a card to change or remove it.
|
These cards hold other cards. Select one in edit mode, pick a card type under **Cards** and press **Add**. The new card opens straight away so you can set it up; **←** takes you back. Use the arrows to reorder cards, and open a card to change or remove it.
|
||||||
@@ -58,6 +61,14 @@ These cards hold other cards. Select one in edit mode, pick a card type under **
|
|||||||
|
|
||||||
Cards inside a container work like anywhere else, and appliance cards inside them still hide themselves when the appliance is off.
|
Cards inside a container work like anywhere else, and appliance cards inside them still hide themselves when the appliance is off.
|
||||||
|
|
||||||
|
### Light groups
|
||||||
|
|
||||||
|
When the light card points at a light group, it also lists every light in the group, each with its own switch and brightness. The group's own switch and slider still control all of them at once. When you pick a group, the card grows to four rows so the list fits. Turn off **For a light group, show each light** to hide the list.
|
||||||
|
|
||||||
|
### Vacuum
|
||||||
|
|
||||||
|
Shows the robot's status and battery, buttons to start or pause, send it back to the dock and locate it, and the suction power levels it supports. Optionally add a map: pick the camera or image entity that your vacuum integration provides (for example Roborock or Valetudo), and it refreshes every few seconds. If the battery level is a separate sensor in your integration, pick it under **Battery sensor**.
|
||||||
|
|
||||||
### Media players
|
### Media players
|
||||||
|
|
||||||
One card can control several players. With more than one, a row of buttons at the top switches between them, and a small animated equalizer marks the ones that are playing. The card starts on whatever is playing. It shows the album art, title and artist, with previous, play/pause and next buttons and a volume slider. A player that is off gets a **Turn on** button instead. You can hide players that are off from the switcher.
|
One card can control several players. With more than one, a row of buttons at the top switches between them, and a small animated equalizer marks the ones that are playing. The card starts on whatever is playing. It shows the album art, title and artist, with previous, play/pause and next buttons and a volume slider. A player that is off gets a **Turn on** button instead. You can hide players that are off from the switcher.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: Dashboard Studio
|
name: Dashboard Studio
|
||||||
version: "1.7.0"
|
version: "1.8.0"
|
||||||
slug: dashboard_studio
|
slug: dashboard_studio
|
||||||
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
|
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
|
||||||
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
|
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ def now_iso():
|
|||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def entity(entity_id, state, **attrs):
|
def entity(eid, state, /, **attrs):
|
||||||
ts = now_iso()
|
ts = now_iso()
|
||||||
return {
|
return {
|
||||||
"entity_id": entity_id,
|
"entity_id": eid,
|
||||||
"state": state,
|
"state": state,
|
||||||
"attributes": attrs,
|
"attributes": attrs,
|
||||||
"last_changed": ts,
|
"last_changed": ts,
|
||||||
@@ -47,6 +47,10 @@ class MockHA:
|
|||||||
entity("light.reading_lamp", "on", friendly_name="Reading Lamp", brightness=140, rgb_color=[255, 140, 60], supported_color_modes=["rgb"]),
|
entity("light.reading_lamp", "on", friendly_name="Reading Lamp", brightness=140, rgb_color=[255, 140, 60], supported_color_modes=["rgb"]),
|
||||||
entity("light.kitchen", "off", friendly_name="Kitchen", brightness=None, supported_color_modes=["brightness"]),
|
entity("light.kitchen", "off", friendly_name="Kitchen", brightness=None, supported_color_modes=["brightness"]),
|
||||||
entity("light.bedroom", "on", friendly_name="Bedroom", brightness=90, supported_color_modes=["brightness"]),
|
entity("light.bedroom", "on", friendly_name="Bedroom", brightness=90, supported_color_modes=["brightness"]),
|
||||||
|
entity("light.downstairs", "on", friendly_name="Downstairs", brightness=160, supported_color_modes=["brightness"],
|
||||||
|
entity_id=["light.living_room", "light.kitchen", "light.reading_lamp"]),
|
||||||
|
entity("vacuum.robot", "docked", friendly_name="Robot Vacuum", battery_level=82, fan_speed="standard",
|
||||||
|
fan_speed_list=["quiet", "standard", "turbo", "max"], status="Charging"),
|
||||||
entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"),
|
entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"),
|
||||||
entity("fan.office", "off", friendly_name="Office Fan"),
|
entity("fan.office", "off", friendly_name="Office Fan"),
|
||||||
entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"),
|
entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"),
|
||||||
@@ -104,6 +108,8 @@ class MockHA:
|
|||||||
new["attributes"].update(attrs)
|
new["attributes"].update(attrs)
|
||||||
new["last_updated"] = now_iso()
|
new["last_updated"] = now_iso()
|
||||||
self.states[entity_id] = new
|
self.states[entity_id] = new
|
||||||
|
if entity_id.startswith("light.") and "entity_id" not in new["attributes"]:
|
||||||
|
self._sync_groups(entity_id)
|
||||||
event = {"event_type": "state_changed", "data": {"entity_id": entity_id, "old_state": old, "new_state": new}, "time_fired": now_iso()}
|
event = {"event_type": "state_changed", "data": {"entity_id": entity_id, "old_state": old, "new_state": new}, "time_fired": now_iso()}
|
||||||
for ws, sub_ids in list(self.clients):
|
for ws, sub_ids in list(self.clients):
|
||||||
for sid in sub_ids:
|
for sid in sub_ids:
|
||||||
@@ -115,13 +121,40 @@ class MockHA:
|
|||||||
except Exception: # client went away
|
except Exception: # client went away
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _vacuum(self, eid, service, data):
|
||||||
|
if service == "start":
|
||||||
|
self.set_state(eid, "cleaning", status="Cleaning")
|
||||||
|
elif service == "pause":
|
||||||
|
self.set_state(eid, "paused", status="Paused")
|
||||||
|
elif service in ("return_to_base", "stop"):
|
||||||
|
self.set_state(eid, "returning", status="Returning home")
|
||||||
|
asyncio.get_event_loop().call_later(6, lambda: self.set_state(eid, "docked", status="Charging"))
|
||||||
|
elif service == "set_fan_speed":
|
||||||
|
self.set_state(eid, None, fan_speed=data.get("fan_speed"))
|
||||||
|
|
||||||
|
def _sync_groups(self, changed):
|
||||||
|
for gid, g in list(self.states.items()):
|
||||||
|
members = g["attributes"].get("entity_id")
|
||||||
|
if not gid.startswith("light.") or not isinstance(members, list) or changed not in members:
|
||||||
|
continue
|
||||||
|
on = [self.states[m] for m in members if m in self.states and self.states[m]["state"] == "on"]
|
||||||
|
bright = round(sum(m["attributes"].get("brightness") or 0 for m in on) / len(on)) if on else None
|
||||||
|
self.set_state(gid, "on" if on else "off", brightness=bright)
|
||||||
|
|
||||||
def call_service(self, domain, service, data):
|
def call_service(self, domain, service, data):
|
||||||
targets = data.get("entity_id") or (data.get("target") or {}).get("entity_id") or []
|
targets = data.get("entity_id") or (data.get("target") or {}).get("entity_id") or []
|
||||||
if isinstance(targets, str):
|
if isinstance(targets, str):
|
||||||
targets = [targets]
|
targets = [targets]
|
||||||
|
expanded = []
|
||||||
for eid in targets:
|
for eid in targets:
|
||||||
|
members = (self.states.get(eid) or {}).get("attributes", {}).get("entity_id")
|
||||||
|
expanded += members if eid.startswith("light.") and isinstance(members, list) else [eid]
|
||||||
|
for eid in expanded:
|
||||||
if eid not in self.states:
|
if eid not in self.states:
|
||||||
continue
|
continue
|
||||||
|
if domain == "vacuum" or eid.startswith("vacuum."):
|
||||||
|
self._vacuum(eid, service, data)
|
||||||
|
continue
|
||||||
st = self.states[eid]["state"]
|
st = self.states[eid]["state"]
|
||||||
if eid.startswith("climate.") and service in ("turn_on", "turn_off", "toggle"):
|
if eid.startswith("climate.") and service in ("turn_on", "turn_off", "toggle"):
|
||||||
on = service == "turn_on" or (service == "toggle" and st == "off")
|
on = service == "turn_on" or (service == "toggle" and st == "off")
|
||||||
|
|||||||
@@ -625,6 +625,9 @@
|
|||||||
${list.map((e, i) => `<div class="ent-item"><input class="input" type="text" list="${lid}" ${data} data-idx="${i}" value="${esc(e)}" autocomplete="off" spellcheck="false" aria-label="${esc(f.l)} ${i + 1}"><button class="btn icon" data-ent-del="${i}" ${data} aria-label="${esc(tr("Remove {0}", e))}">${Icons.svg("close")}</button></div>`).join("")}
|
${list.map((e, i) => `<div class="ent-item"><input class="input" type="text" list="${lid}" ${data} data-idx="${i}" value="${esc(e)}" autocomplete="off" spellcheck="false" aria-label="${esc(f.l)} ${i + 1}"><button class="btn icon" data-ent-del="${i}" ${data} aria-label="${esc(tr("Remove {0}", e))}">${Icons.svg("close")}</button></div>`).join("")}
|
||||||
<input class="input" type="text" list="${lid}" ${data} data-idx="new" placeholder="${tr("Add an entity…")}" autocomplete="off" spellcheck="false" aria-label="Add to ${esc(f.l)}"></div>`;
|
<input class="input" type="text" list="${lid}" ${data} data-idx="new" placeholder="${tr("Add an entity…")}" autocomplete="off" spellcheck="false" aria-label="Add to ${esc(f.l)}"></div>`;
|
||||||
}
|
}
|
||||||
|
case "pos9": // a 3×3 grid for placing content in the card
|
||||||
|
return `<div class="field"><span class="field-label" id="${id}">${esc(f.l)}</span><div class="pos9" role="group" aria-labelledby="${id}">
|
||||||
|
${f.o.map((o) => `<button type="button" ${data} data-v="${o}" aria-pressed="${o === value}" aria-label="${esc(optLabel(o))}" title="${esc(optLabel(o))}"><i></i></button>`).join("")}</div></div>`;
|
||||||
case "albums":
|
case "albums":
|
||||||
return `<div class="field"><span class="field-label">${esc(f.l)}</span><div class="albums" ${data} data-selected="${esc(JSON.stringify(value || []))}"><div class="muted small">${tr("Loading albums…")}</div></div></div>`;
|
return `<div class="field"><span class="field-label">${esc(f.l)}</span><div class="albums" ${data} data-selected="${esc(JSON.stringify(value || []))}"><div class="muted small">${tr("Loading albums…")}</div></div></div>`;
|
||||||
default:
|
default:
|
||||||
@@ -874,6 +877,8 @@
|
|||||||
if (path === "cfg") {
|
if (path === "cfg") {
|
||||||
if (idx != null) w.cfg[k][Number(idx)] = v;
|
if (idx != null) w.cfg[k][Number(idx)] = v;
|
||||||
else w.cfg[k] = v;
|
else w.cfg[k] = v;
|
||||||
|
// A light group lists its lights underneath, which needs room.
|
||||||
|
if (w.type === "light" && k === "entity" && Array.isArray(ha.states[v]?.attributes.entity_id) && w.h && w.h < 4) { w.h = 4; resolveCollisions(w); }
|
||||||
} else if (path === "style") { (w.style ||= {})[k] = v; }
|
} else if (path === "style") { (w.style ||= {})[k] = v; }
|
||||||
else if (path === "pos") {
|
else if (path === "pos") {
|
||||||
const n = Math.max(k === "w" || k === "h" ? 1 : 0, Math.round(Number(v) || 0));
|
const n = Math.max(k === "w" || k === "h" ? 1 : 0, Math.round(Number(v) || 0));
|
||||||
@@ -889,7 +894,7 @@
|
|||||||
} else if (path === "grid") { current.grid[k] = v === "" ? 0 : v; }
|
} else if (path === "grid") { current.grid[k] = v === "" ? 0 : v; }
|
||||||
else if (path === "kiosk") { current.kiosk[k] = v; }
|
else if (path === "kiosk") { current.kiosk[k] = v; }
|
||||||
else if (path === "dash") { current.name = v; renderDashSelect(); }
|
else if (path === "dash") { current.name = v; renderDashSelect(); }
|
||||||
}, key, { rerender: ["pos", "theme", "grid"].includes(path) });
|
}, key, { rerender: ["pos", "theme", "grid"].includes(path) || (path === "cfg" && k === "entity") });
|
||||||
|
|
||||||
if (path === "cfg" || path === "style") renderWidget(selectedWidget());
|
if (path === "cfg" || path === "style") renderWidget(selectedWidget());
|
||||||
const fieldType = w && Widgets[w.type].fields.find((f) => f.k === k)?.t;
|
const fieldType = w && Widgets[w.type].fields.find((f) => f.k === k)?.t;
|
||||||
@@ -903,7 +908,7 @@
|
|||||||
if (albums) {
|
if (albums) {
|
||||||
if (e.type !== "change") return;
|
if (e.type !== "change") return;
|
||||||
const ids = [...albums.querySelectorAll("input:checked")].map((i) => i.value);
|
const ids = [...albums.querySelectorAll("input:checked")].map((i) => i.value);
|
||||||
applyField({ dataset: { path: "cfg", k: "albums" }, tagName: "DIV", classList: { add() {}, remove() {} } }, ids, false);
|
applyField({ dataset: { path: "cfg", k: albums.dataset.k }, tagName: "DIV", classList: { add() {}, remove() {} } }, ids, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (t.dataset.sync) { // color swatch -> text field
|
if (t.dataset.sync) { // color swatch -> text field
|
||||||
@@ -1062,6 +1067,7 @@
|
|||||||
const t = Math.round(((s?.attributes.temperature ?? 20) + Number(a.dataset.v)) * 10) / 10;
|
const t = Math.round(((s?.attributes.temperature ?? 20) + Number(a.dataset.v)) * 10) / 10;
|
||||||
ha.callService("climate", "set_temperature", { temperature: t }, eid);
|
ha.callService("climate", "set_temperature", { temperature: t }, eid);
|
||||||
} else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid);
|
} else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid);
|
||||||
|
else if (act === "vac-fan") ha.callService("vacuum", "set_fan_speed", { fan_speed: a.dataset.v }, eid);
|
||||||
else if (act === "mp-select") {
|
else if (act === "mp-select") {
|
||||||
const box = a.closest(".w"), w = findWidget(box.dataset.id);
|
const box = a.closest(".w"), w = findWidget(box.dataset.id);
|
||||||
caches[w.id].sel = a.dataset.v;
|
caches[w.id].sel = a.dataset.v;
|
||||||
@@ -1417,7 +1423,7 @@
|
|||||||
insp.addEventListener("input", onInspectorInput);
|
insp.addEventListener("input", onInspectorInput);
|
||||||
insp.addEventListener("change", onInspectorInput);
|
insp.addEventListener("change", onInspectorInput);
|
||||||
insp.addEventListener("click", (e) => {
|
insp.addEventListener("click", (e) => {
|
||||||
const seg = e.target.closest(".seg button[data-path]");
|
const seg = e.target.closest(".seg button[data-path], .pos9 button[data-path]");
|
||||||
if (seg) { applyField(seg, seg.dataset.v, true); return; }
|
if (seg) { applyField(seg, seg.dataset.v, true); return; }
|
||||||
const p = e.target.closest("[data-preset]");
|
const p = e.target.closest("[data-preset]");
|
||||||
if (p) {
|
if (p) {
|
||||||
|
|||||||
@@ -258,6 +258,16 @@
|
|||||||
"Name this device, e.g. Kitchen tablet": "Geef dit apparaat een naam, bijv. Keukentablet", "Unnamed device": "Apparaat zonder naam",
|
"Name this device, e.g. Kitchen tablet": "Geef dit apparaat een naam, bijv. Keukentablet", "Unnamed device": "Apparaat zonder naam",
|
||||||
"Device name": "Apparaatnaam", "Forget this device": "Vergeet dit apparaat", "you": "jij", "Dashboard for {0}": "Dashboard voor {0}",
|
"Device name": "Apparaatnaam", "Forget this device": "Vergeet dit apparaat", "you": "jij", "Dashboard for {0}": "Dashboard voor {0}",
|
||||||
"People appear here once they have opened Dashboard Studio, or when they have a person entity.": "Personen verschijnen hier zodra ze Dashboard Studio hebben geopend, of als ze een persoonsentiteit hebben.",
|
"People appear here once they have opened Dashboard Studio, or when they have a person entity.": "Personen verschijnen hier zodra ze Dashboard Studio hebben geopend, of als ze een persoonsentiteit hebben.",
|
||||||
|
"Text position": "Positie van de tekst", "Background image": "Achtergrondafbeelding", "none": "Geen",
|
||||||
|
"Darken the image (%)": "Afbeelding donkerder maken (%)",
|
||||||
|
"top-left": "Linksboven", "top": "Boven", "top-right": "Rechtsboven", "left": "Links", "center": "Midden", "right": "Rechts",
|
||||||
|
"bottom-left": "Linksonder", "bottom": "Onder", "bottom-right": "Rechtsonder",
|
||||||
|
"For a light group, show each light": "Toon bij een lampengroep elke lamp", "Lights in {0}": "Lampen in {0}",
|
||||||
|
"Vacuum": "Stofzuiger", "a vacuum": "een stofzuiger", "Map camera (optional)": "Kaartcamera (optioneel)",
|
||||||
|
"Refresh the map every (seconds)": "Vernieuw de kaart elke (seconden)", "Battery sensor (if not on the vacuum)": "Accusensor (als de stofzuiger die niet zelf heeft)",
|
||||||
|
"Show suction power": "Toon zuigkracht", "Battery {0}%": "Accu {0}%", "Cleaning map": "Schoonmaakkaart", "Suction power": "Zuigkracht",
|
||||||
|
"Start cleaning": "Begin met schoonmaken", "Send back to the dock": "Terug naar het dock", "Locate (makes a sound)": "Zoeken (maakt geluid)",
|
||||||
|
"Cleaning…": "Aan het schoonmaken…", "On its way to the dock": "Onderweg naar het dock",
|
||||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -268,7 +278,7 @@
|
|||||||
locking: "Vergrendelen", unlocking: "Ontgrendelen", jammed: "Vastgelopen", playing: "Speelt", paused: "Gepauzeerd",
|
locking: "Vergrendelen", unlocking: "Ontgrendelen", jammed: "Vastgelopen", playing: "Speelt", paused: "Gepauzeerd",
|
||||||
idle: "Inactief", standby: "Stand-by", buffering: "Bufferen", heat: "Verwarmen", cool: "Koelen", auto: "Automatisch",
|
idle: "Inactief", standby: "Stand-by", buffering: "Bufferen", heat: "Verwarmen", cool: "Koelen", auto: "Automatisch",
|
||||||
heat_cool: "Verwarmen/koelen", dry: "Drogen", fan_only: "Alleen ventilator", cleaning: "Reinigen", docked: "Gedockt",
|
heat_cool: "Verwarmen/koelen", dry: "Drogen", fan_only: "Alleen ventilator", cleaning: "Reinigen", docked: "Gedockt",
|
||||||
returning: "Keert terug", charging: "Laden", above_horizon: "Boven de horizon", below_horizon: "Onder de horizon",
|
returning: "Keert terug", error: "Fout", charging: "Laden", above_horizon: "Boven de horizon", below_horizon: "Onder de horizon",
|
||||||
"clear-night": "Helder", cloudy: "Bewolkt", exceptional: "Uitzonderlijk", fog: "Mist", hail: "Hagel", lightning: "Onweer",
|
"clear-night": "Helder", cloudy: "Bewolkt", exceptional: "Uitzonderlijk", fog: "Mist", hail: "Hagel", lightning: "Onweer",
|
||||||
"lightning-rainy": "Onweer en regen", partlycloudy: "Halfbewolkt", pouring: "Stortregen", rainy: "Regen", snowy: "Sneeuw",
|
"lightning-rainy": "Onweer en regen", partlycloudy: "Halfbewolkt", pouring: "Stortregen", rainy: "Regen", snowy: "Sneeuw",
|
||||||
"snowy-rainy": "Natte sneeuw", sunny: "Zonnig", windy: "Winderig", "windy-variant": "Winderig", armed_away: "Ingeschakeld (afwezig)",
|
"snowy-rainy": "Natte sneeuw", sunny: "Zonnig", windy: "Winderig", "windy-variant": "Winderig", armed_away: "Ingeschakeld (afwezig)",
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ textarea.input { resize: vertical; min-height: 76px; font-family: ui-monospace,
|
|||||||
color: var(--lit-text);
|
color: var(--lit-text);
|
||||||
--d-text-2: var(--lit-text-2);
|
--d-text-2: var(--lit-text-2);
|
||||||
--d-fill: rgba(61, 44, 10, 0.12);
|
--d-fill: rgba(61, 44, 10, 0.12);
|
||||||
|
--d-separator: rgba(61, 44, 10, 0.14);
|
||||||
}
|
}
|
||||||
.w.lit-lamp .badge { background: rgba(255, 255, 255, 0.55); color: var(--lit-text); }
|
.w.lit-lamp .badge { background: rgba(255, 255, 255, 0.55); color: var(--lit-text); }
|
||||||
.surface-plain .w.lit-lamp, .surface-outline .w.lit-lamp { background: transparent; color: var(--w-color, var(--d-text)); --d-text-2: var(--text-2); --d-fill: var(--fill); }
|
.surface-plain .w.lit-lamp, .surface-outline .w.lit-lamp { background: transparent; color: var(--w-color, var(--d-text)); --d-text-2: var(--text-2); --d-fill: var(--fill); }
|
||||||
@@ -367,7 +368,7 @@ body.editing .w:hover .w-resize, body.editing .w.selected .w-resize { opacity: 1
|
|||||||
.clock-wx-text { font-size: 14px; color: var(--d-text-2); }
|
.clock-wx-text { font-size: 14px; color: var(--d-text-2); }
|
||||||
.clock-hl { font-size: 14px; color: var(--d-text-2); }
|
.clock-hl { font-size: 14px; color: var(--d-text-2); }
|
||||||
.clock-hl:empty { display: none; }
|
.clock-hl:empty { display: none; }
|
||||||
@container (max-height: 150px) { .clock-wx-text, .clock-hl { display: none; } .clock-wx { margin-top: 6px; } }
|
@container (max-height: 175px) { .clock-wx-text, .clock-hl { display: none; } .clock-wx { margin-top: 6px; } }
|
||||||
|
|
||||||
.cam-btn { position: absolute; inset: 0; }
|
.cam-btn { position: absolute; inset: 0; }
|
||||||
.cam { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
|
.cam { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
|
||||||
@@ -549,6 +550,41 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
|||||||
.child-open .i { font-size: 18px; flex: none; }
|
.child-open .i { font-size: 18px; flex: none; }
|
||||||
.child-item .btn.icon { width: 30px; min-width: 30px; flex: none; }
|
.child-item .btn.icon { width: 30px; min-width: 30px; flex: none; }
|
||||||
|
|
||||||
|
/* clock: text position and background image */
|
||||||
|
.clock { position: relative; }
|
||||||
|
.clock-content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: inherit; text-align: inherit; max-width: 100%; }
|
||||||
|
.clock-content .clock-time { align-items: inherit; }
|
||||||
|
.clock.pos-top-left, .clock.pos-top, .clock.pos-top-right { justify-content: flex-start; }
|
||||||
|
.clock.pos-bottom-left, .clock.pos-bottom, .clock.pos-bottom-right { justify-content: flex-end; }
|
||||||
|
.clock.pos-top-left, .clock.pos-left, .clock.pos-bottom-left { align-items: flex-start; text-align: left; }
|
||||||
|
.clock.pos-top-right, .clock.pos-right, .clock.pos-bottom-right { align-items: flex-end; text-align: right; }
|
||||||
|
.clock-bg { position: absolute; inset: 0; margin: -16px; z-index: 0; border-radius: inherit; }
|
||||||
|
.w.transparent .clock-bg, .surface-plain .clock-bg { margin: -6px -4px; }
|
||||||
|
.clock-dim { position: absolute; inset: 0; margin: -16px; z-index: 0; background: linear-gradient(to bottom, rgba(0, 0, 0, calc(var(--dim) * 0.8)), rgba(0, 0, 0, var(--dim))); }
|
||||||
|
.clock.on-image { color: #fff; --d-text-2: rgba(255, 255, 255, 0.86); text-shadow: 0 1px 12px rgba(0, 0, 0, 0.45); }
|
||||||
|
.clock.on-image .clock-wx { background: rgba(0, 0, 0, 0.32); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
||||||
|
.pos9 { display: grid; grid-template-columns: repeat(3, 34px); gap: 4px; padding: 4px; background: var(--fill); border-radius: 10px; width: max-content; }
|
||||||
|
.pos9 button { width: 34px; height: 28px; border: 0; border-radius: 7px; background: transparent; cursor: pointer; display: grid; place-items: center; }
|
||||||
|
.pos9 button i { width: 8px; height: 8px; border-radius: 50%; background: var(--text-2); opacity: .4; }
|
||||||
|
.pos9 button[aria-pressed="true"] { background: var(--surface); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); }
|
||||||
|
.pos9 button[aria-pressed="true"] i { width: 14px; height: 10px; border-radius: 3px; background: var(--accent); opacity: 1; }
|
||||||
|
|
||||||
|
/* light groups */
|
||||||
|
.light-members { flex: 1; min-height: 0; margin: 0 -6px; }
|
||||||
|
.light:has(.light-members) { justify-content: flex-start; }
|
||||||
|
|
||||||
|
/* vacuum */
|
||||||
|
.vac { display: flex; flex-direction: column; height: 100%; gap: 10px; }
|
||||||
|
.vac-batt { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 14px; font-weight: 600; color: var(--d-text-2); }
|
||||||
|
.vac-batt .i { font-size: 18px; }
|
||||||
|
.vac-map { flex: 1; min-height: 0; width: 100%; object-fit: contain; border-radius: 12px; background: var(--d-fill); }
|
||||||
|
.vac-spacer { flex: 1; }
|
||||||
|
.vac .ctrls { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.vac .round:disabled { opacity: .35; cursor: default; }
|
||||||
|
.vac-hint { margin-left: 4px; }
|
||||||
|
.vac.is-error .state { color: var(--crit); font-weight: 600; }
|
||||||
|
@container (max-height: 220px) { .vac-map { display: none; } .vac-spacer { display: none; } }
|
||||||
|
|
||||||
/* photos */
|
/* photos */
|
||||||
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
|
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
|
||||||
.ph-layer { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: var(--fit, cover); opacity: 0; transition: opacity 1.2s ease; }
|
.ph-layer { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: var(--fit, cover); opacity: 0; transition: opacity 1.2s ease; }
|
||||||
|
|||||||
+107
-42
@@ -207,10 +207,14 @@
|
|||||||
fields: [
|
fields: [
|
||||||
{ k: "entity", t: "entity", l: "Light", domains: ["light"] },
|
{ k: "entity", t: "entity", l: "Light", domains: ["light"] },
|
||||||
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||||
|
{ k: "members", t: "check", l: "For a light group, show each light", d: true },
|
||||||
],
|
],
|
||||||
|
// A light group lists its lights in the entity_id attribute; follow them too.
|
||||||
|
entities: (c, ha) => [c.entity, ...(ha?.states[c.entity]?.attributes.entity_id || [])].filter(Boolean),
|
||||||
render(c, { ha }) {
|
render(c, { ha }) {
|
||||||
const s = ha.states[c.entity];
|
const s = ha.states[c.entity];
|
||||||
if (!s) return missing(c.entity, "a light");
|
if (!s) return missing(c.entity, "a light");
|
||||||
|
const members = c.members !== false && Array.isArray(s.attributes.entity_id) ? s.attributes.entity_id : [];
|
||||||
const pct = s.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0;
|
const pct = s.state === "on" ? Math.round((s.attributes.brightness || 255) / 2.55) : 0;
|
||||||
const dimmable = (s.attributes.supported_color_modes || []).some((m) => m !== "onoff");
|
const dimmable = (s.attributes.supported_color_modes || []).some((m) => m !== "onoff");
|
||||||
const name = friendly(s, c.name);
|
const name = friendly(s, c.name);
|
||||||
@@ -218,7 +222,12 @@
|
|||||||
<button class="tile-row wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}">
|
<button class="tile-row wbtn" data-act="toggle" data-e="${esc(c.entity)}" aria-pressed="${isOn(s)}">
|
||||||
<span class="badge">${icon(s)}</span>
|
<span class="badge">${icon(s)}</span>
|
||||||
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(fmtState(s))}</span></span></button>
|
<span class="tile-text"><span class="name">${esc(name)}</span><span class="state">${esc(fmtState(s))}</span></span></button>
|
||||||
${dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(name)} brightness">` : ""}</div>`;
|
${dimmable ? `<input class="slider" type="range" min="1" max="100" value="${pct || 1}" style="--pct:${pct}%" data-act="brightness" data-e="${esc(c.entity)}" aria-label="${esc(name)} brightness">` : ""}
|
||||||
|
${members.length ? `<div class="list light-members" role="group" aria-label="${t("Lights in {0}", esc(name))}">${members.map((eid) => {
|
||||||
|
const m = ha.states[eid];
|
||||||
|
if (!m) return "";
|
||||||
|
return `<button class="list-row wbtn" role="switch" aria-checked="${isOn(m)}" data-act="toggle" data-e="${esc(eid)}"><span class="row-ico">${icon(m)}</span><span class="grow">${esc(friendly(m))}</span><span class="st">${esc(fmtState(m))}</span><span class="switch"></span></button>`;
|
||||||
|
}).join("")}</div>` : ""}</div>`;
|
||||||
},
|
},
|
||||||
lit: (c, { ha }) => litKind(ha.states[c.entity]),
|
lit: (c, { ha }) => litKind(ha.states[c.entity]),
|
||||||
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
|
lamp: (c, { ha }) => lampColor(ha.states[c.entity]),
|
||||||
@@ -324,24 +333,38 @@
|
|||||||
<text class="axis" x="${L}" y="${H - 2}">${t("−{0} h", hours)}</text><text class="axis" x="${W_}" y="${H - 2}" text-anchor="end">${t("Now")}</text></svg>`;
|
<text class="axis" x="${L}" y="${H - 2}">${t("−{0} h", hours)}</text><text class="axis" x="${W_}" y="${H - 2}" text-anchor="end">${t("Now")}</text></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const POSITIONS = ["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"];
|
||||||
|
|
||||||
W.clock = {
|
W.clock = {
|
||||||
label: "Clock", icon: "clock", size: [3, 2],
|
label: "Clock", icon: "clock", size: [3, 2],
|
||||||
fields: [
|
fields: [
|
||||||
{ k: "h24", t: "check", l: "24-hour time", d: true },
|
{ k: "h24", t: "check", l: "24-hour time", d: true },
|
||||||
{ k: "seconds", t: "check", l: "Show seconds", d: false },
|
{ k: "seconds", t: "check", l: "Show seconds", d: false },
|
||||||
{ k: "date", t: "check", l: "Show date", d: true },
|
{ k: "date", t: "check", l: "Show date", d: true },
|
||||||
|
{ k: "pos", t: "pos9", l: "Text position", o: POSITIONS, d: "center" },
|
||||||
{ k: "weather", t: "entity", l: "Weather (optional)", domains: ["weather"] },
|
{ k: "weather", t: "entity", l: "Weather (optional)", domains: ["weather"] },
|
||||||
{ k: "forecast", t: "check", l: "Show today’s high and low", d: true },
|
{ k: "forecast", t: "check", l: "Show today’s high and low", d: true },
|
||||||
|
{ k: "bgSource", t: "seg", l: "Background image", o: ["none", "links", "immich"], d: "none" },
|
||||||
|
{ k: "bgUrls", t: "textarea", l: "Image URLs, one per line", d: "", when: (c) => c.bgSource === "links" },
|
||||||
|
{ k: "bgAlbums", t: "albums", l: "Immich albums", when: (c) => c.bgSource === "immich" },
|
||||||
|
{ k: "bgInterval", t: "number", l: "Change photo every (seconds)", d: 60, when: (c) => c.bgSource && c.bgSource !== "none" },
|
||||||
|
{ k: "dim", t: "number", l: "Darken the image (%)", d: 30, when: (c) => c.bgSource && c.bgSource !== "none" },
|
||||||
],
|
],
|
||||||
entities: (c) => (c.weather ? [c.weather] : []),
|
entities: (c) => (c.weather ? [c.weather] : []),
|
||||||
render(c, { ha }) {
|
render(c, { ha }) {
|
||||||
const s = c.weather && ha.states[c.weather];
|
const s = c.weather && ha.states[c.weather];
|
||||||
|
const bg = c.bgSource === "links" ? urlList({ urls: c.bgUrls }).length > 0 : c.bgSource === "immich" && c.bgAlbums?.length > 0;
|
||||||
|
const pos = POSITIONS.includes(c.pos) ? c.pos : "center";
|
||||||
const wx = s ? `<button class="clock-wx wbtn" data-act="more" data-e="${esc(c.weather)}" aria-label="${esc(friendly(s))}: ${esc(st(s.state))}, ${esc(s.attributes.temperature ?? "–")}°">
|
const wx = s ? `<button class="clock-wx wbtn" data-act="more" data-e="${esc(c.weather)}" aria-label="${esc(friendly(s))}: ${esc(st(s.state))}, ${esc(s.attributes.temperature ?? "–")}°">
|
||||||
<span class="clock-wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span><span class="clock-wx-temp num">${esc(s.attributes.temperature ?? "–")}°</span>
|
<span class="clock-wx-ico">${Icons.svg(WX[s.state] || "cloud")}</span><span class="clock-wx-temp num">${esc(s.attributes.temperature ?? "–")}°</span>
|
||||||
<span class="clock-wx-text">${esc(st(s.state))}</span><span class="clock-hl num" data-hl></span></button>` : "";
|
<span class="clock-wx-text">${esc(st(s.state))}</span><span class="clock-hl num" data-hl></span></button>` : "";
|
||||||
return `<div class="clock"><div class="clock-time" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="${c.seconds ? 1 : 0}" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, c.seconds, c.date)}</div>${wx}</div>`;
|
return `<div class="clock pos-${pos} ${bg ? "on-image" : ""}" style="--dim:${Math.min(90, Math.max(0, Number(c.dim ?? 30))) / 100}">
|
||||||
|
${bg ? `<div class="photos clock-bg" aria-hidden="true"><img class="ph-layer" alt=""><img class="ph-layer" alt=""></div><div class="clock-dim"></div>` : ""}
|
||||||
|
<div class="clock-content"><div class="clock-time" data-clock data-h24="${c.h24 ? 1 : 0}" data-sec="${c.seconds ? 1 : 0}" data-date="${c.date ? 1 : 0}">${clockHTML(c.h24, c.seconds, c.date)}</div>${wx}</div></div>`;
|
||||||
},
|
},
|
||||||
async after(el, c, ctx) {
|
async after(el, c, ctx) {
|
||||||
|
const box = el.querySelector(".clock-bg");
|
||||||
|
if (box) slideshow(el, box, ctx, (ctx.cache.bg ||= {}), { source: c.bgSource, urls: c.bgUrls, albums: c.bgAlbums, shuffle: true, interval: c.bgInterval || 60 });
|
||||||
if (!c.weather || c.forecast === false || !ctx.ha.states[c.weather]) return;
|
if (!c.weather || c.forecast === false || !ctx.ha.states[c.weather]) return;
|
||||||
const cache = ctx.cache;
|
const cache = ctx.cache;
|
||||||
if (cache.key !== c.weather || Date.now() - cache.at > 30 * 60 * 1000) {
|
if (cache.key !== c.weather || Date.now() - cache.at > 30 * 60 * 1000) {
|
||||||
@@ -453,6 +476,43 @@
|
|||||||
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
|
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- vacuum
|
||||||
|
W.vacuum = {
|
||||||
|
label: "Vacuum", icon: "vacuum", size: [4, 3],
|
||||||
|
fields: [
|
||||||
|
{ k: "entity", t: "entity", l: "Vacuum", domains: ["vacuum"] },
|
||||||
|
{ k: "name", t: "text", l: "Name", ph: "Entity name" },
|
||||||
|
{ k: "map", t: "entity", l: "Map camera (optional)", domains: ["camera", "image"] },
|
||||||
|
{ k: "refresh", t: "number", l: "Refresh the map every (seconds)", d: 5, when: (c) => !!c.map },
|
||||||
|
{ k: "battery", t: "entity", l: "Battery sensor (if not on the vacuum)", domains: ["sensor"] },
|
||||||
|
{ k: "fan", t: "check", l: "Show suction power", d: true },
|
||||||
|
],
|
||||||
|
entities: (c) => [c.entity, c.battery].filter(Boolean),
|
||||||
|
render(c, { ha }) {
|
||||||
|
const s = ha.states[c.entity];
|
||||||
|
if (!s) return missing(c.entity, "a vacuum");
|
||||||
|
const a = s.attributes, e = esc(c.entity);
|
||||||
|
const cleaning = s.state === "cleaning", busy = cleaning || s.state === "returning";
|
||||||
|
const batt = Number(c.battery ? ha.states[c.battery]?.state : a.battery_level);
|
||||||
|
const status = a.status && String(a.status).toLowerCase() !== s.state ? `${st(s.state)} · ${a.status}` : st(s.state);
|
||||||
|
const mapUrl = c.map ? (domainOf(c.map) === "image" ? ha.states[c.map]?.attributes.entity_picture : `api/camera/${encodeURIComponent(c.map)}?t=${Date.now()}`) : "";
|
||||||
|
const speeds = c.fan !== false && Array.isArray(a.fan_speed_list) ? a.fan_speed_list : [];
|
||||||
|
return `<div class="vac ${s.state === "error" ? "is-error" : ""}">
|
||||||
|
<div class="tile-row"><span class="badge">${Icons.svg("vacuum")}</span>
|
||||||
|
<span class="tile-text"><span class="name">${esc(friendly(s, c.name))}</span><span class="state">${esc(status)}</span></span>
|
||||||
|
${isFinite(batt) ? `<span class="vac-batt" aria-label="${t("Battery {0}%", Math.round(batt))}">${Icons.svg("battery")}<span class="num">${Math.round(batt)}%</span></span>` : ""}</div>
|
||||||
|
${mapUrl ? `<img class="vac-map" src="${esc(mapUrl)}" ${domainOf(c.map) === "camera" ? `data-cam="${esc(c.map)}" data-refresh="${Number(c.refresh) || 5}"` : ""} alt="${t("Cleaning map")}">` : `<div class="vac-spacer"></div>`}
|
||||||
|
${speeds.length ? `<div class="chips" role="group" aria-label="${t("Suction power")}">${speeds.map((f) => `<button class="chip wbtn" aria-pressed="${f === a.fan_speed}" data-act="vac-fan" data-e="${e}" data-v="${esc(f)}">${esc(cap(f))}</button>`).join("")}</div>` : ""}
|
||||||
|
<div class="ctrls">
|
||||||
|
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="vacuum.${cleaning ? "pause" : "start"}" aria-label="${cleaning ? t("Pause") : t("Start cleaning")}">${Icons.svg(cleaning ? "pause" : "play")}</button>
|
||||||
|
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="vacuum.return_to_base" aria-label="${t("Send back to the dock")}" ${s.state === "docked" ? "disabled" : ""}>${Icons.svg("home")}</button>
|
||||||
|
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="vacuum.locate" aria-label="${t("Locate (makes a sound)")}">${Icons.svg("pin")}</button>
|
||||||
|
${busy ? `<span class="wt vac-hint">${cleaning ? t("Cleaning…") : t("On its way to the dock")}</span>` : ""}
|
||||||
|
</div></div>`;
|
||||||
|
},
|
||||||
|
lit: (c, { ha }) => (["cleaning", "returning"].includes(ha.states[c.entity]?.state) ? "on" : false),
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------- appliance (washer, dryer, dishwasher)
|
// ---------------------------------------------------------------- appliance (washer, dryer, dishwasher)
|
||||||
// Works with any integration: a status entity, and/or a power sensor above a threshold,
|
// Works with any integration: a status entity, and/or a power sensor above a threshold,
|
||||||
// plus optional remaining time (minutes, hours or an end timestamp), progress and program.
|
// plus optional remaining time (minutes, hours or an end timestamp), progress and program.
|
||||||
@@ -637,49 +697,54 @@
|
|||||||
return `<div class="photos" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
|
return `<div class="photos" style="--fit:${esc(c.fit || "cover")}" role="img" aria-label="${esc(c.alt || t("Photo slideshow"))}">
|
||||||
<img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-date"></div><div class="ph-msg"><div class="empty">${t("Loading photos…")}</div></div></div>`;
|
<img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-date"></div><div class="ph-msg"><div class="empty">${t("Loading photos…")}</div></div></div>`;
|
||||||
},
|
},
|
||||||
async after(el, c, ctx) {
|
after(el, c, ctx) {
|
||||||
const cache = ctx.cache;
|
return slideshow(el, el.querySelector(".photos"), ctx, ctx.cache, { source: c.source, urls: c.urls ?? c.url, albums: c.albums, shuffle: c.shuffle, interval: c.interval, showDate: c.showDate });
|
||||||
clearTimeout(cache.timer);
|
|
||||||
const box = el.querySelector(".photos");
|
|
||||||
if (!box) return;
|
|
||||||
const msg = box.querySelector(".ph-msg");
|
|
||||||
const key = JSON.stringify([c.source, c.urls ?? c.url, c.albums, c.shuffle]);
|
|
||||||
if (cache.key !== key || Date.now() - cache.at > 15 * 60 * 1000) {
|
|
||||||
cache.key = key; cache.at = Date.now(); cache.pos = 0;
|
|
||||||
cache.loading = (async () => {
|
|
||||||
if (c.source !== "immich") return urlList(c).map((src) => ({ src }));
|
|
||||||
const lists = await Promise.all((c.albums || []).map((id) => ctx.api(`api/immich/albums/${id}/assets`)));
|
|
||||||
return lists.flat().map((a) => ({ src: `api/immich/assets/${a.id}/preview`, date: a.date }));
|
|
||||||
})().then((items) => {
|
|
||||||
if (c.shuffle) for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; }
|
|
||||||
cache.items = items; cache.error = null;
|
|
||||||
}).catch((e) => { cache.items = []; cache.error = e.message; });
|
|
||||||
}
|
|
||||||
await cache.loading;
|
|
||||||
if (!el.isConnected) return;
|
|
||||||
if (cache.error) { msg.innerHTML = `<div class="empty">${t("Couldn’t load photos. {0}", esc(cache.error))}</div>`; return; }
|
|
||||||
if (!cache.items.length) { msg.innerHTML = `<div class="empty">${t("These albums have no photos yet.")}</div>`; return; }
|
|
||||||
|
|
||||||
const layers = box.querySelectorAll(".ph-layer"), dateEl = box.querySelector(".ph-date");
|
|
||||||
let front = 0;
|
|
||||||
const show = () => {
|
|
||||||
if (!el.isConnected) return;
|
|
||||||
const item = cache.items[cache.pos % cache.items.length];
|
|
||||||
cache.pos++;
|
|
||||||
const next = layers[1 - front];
|
|
||||||
next.onload = () => {
|
|
||||||
next.classList.add("on"); layers[front].classList.remove("on"); front = 1 - front;
|
|
||||||
msg.innerHTML = "";
|
|
||||||
dateEl.textContent = c.showDate && item.date ? new Date(item.date).toLocaleDateString(LOCALE, { day: "numeric", month: "long", year: "numeric" }) : "";
|
|
||||||
};
|
|
||||||
next.onerror = () => { if (cache.items.length > 1) cache.timer = setTimeout(show, 500); };
|
|
||||||
next.src = item.src;
|
|
||||||
if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(c.interval) || 20) * 1000);
|
|
||||||
};
|
|
||||||
show();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Load photos from links or Immich albums and cross-fade through them in `box` (.photos with two .ph-layer images).
|
||||||
|
* Used by the Photos card and as a clock background; `cache` keeps the list and position across redraws. */
|
||||||
|
async function slideshow(el, box, ctx, cache, o) {
|
||||||
|
clearTimeout(cache.timer);
|
||||||
|
if (!box) return;
|
||||||
|
const msg = box.querySelector(".ph-msg");
|
||||||
|
const key = JSON.stringify([o.source, o.urls, o.albums, o.shuffle]);
|
||||||
|
if (cache.key !== key || Date.now() - cache.at > 15 * 60 * 1000) {
|
||||||
|
cache.key = key; cache.at = Date.now(); cache.pos = 0;
|
||||||
|
cache.loading = (async () => {
|
||||||
|
if (o.source !== "immich") return urlList({ urls: o.urls }).map((src) => ({ src }));
|
||||||
|
const lists = await Promise.all((o.albums || []).map((id) => ctx.api(`api/immich/albums/${id}/assets`)));
|
||||||
|
return lists.flat().map((a) => ({ src: `api/immich/assets/${a.id}/preview`, date: a.date }));
|
||||||
|
})().then((items) => {
|
||||||
|
if (o.shuffle) for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; }
|
||||||
|
cache.items = items; cache.error = null;
|
||||||
|
}).catch((e) => { cache.items = []; cache.error = e.message; });
|
||||||
|
}
|
||||||
|
await cache.loading;
|
||||||
|
if (!el.isConnected) return;
|
||||||
|
const say = (text) => { if (msg) msg.innerHTML = `<div class="empty">${text}</div>`; };
|
||||||
|
if (cache.error) { say(t("Couldn’t load photos. {0}", esc(cache.error))); return; }
|
||||||
|
if (!cache.items.length) { say(t("These albums have no photos yet.")); return; }
|
||||||
|
|
||||||
|
const layers = box.querySelectorAll(".ph-layer"), dateEl = box.querySelector(".ph-date");
|
||||||
|
let front = 0;
|
||||||
|
const show = () => {
|
||||||
|
if (!el.isConnected) return;
|
||||||
|
const item = cache.items[cache.pos % cache.items.length];
|
||||||
|
cache.pos++;
|
||||||
|
const next = layers[1 - front];
|
||||||
|
next.onload = () => {
|
||||||
|
next.classList.add("on"); layers[front].classList.remove("on"); front = 1 - front;
|
||||||
|
if (msg) msg.innerHTML = "";
|
||||||
|
if (dateEl) dateEl.textContent = o.showDate && item.date ? new Date(item.date).toLocaleDateString(LOCALE, { day: "numeric", month: "long", year: "numeric" }) : "";
|
||||||
|
};
|
||||||
|
next.onerror = () => { if (cache.items.length > 1) cache.timer = setTimeout(show, 500); };
|
||||||
|
next.src = item.src;
|
||||||
|
if (cache.items.length > 1) cache.timer = setTimeout(show, Math.max(3, Number(o.interval) || 20) * 1000);
|
||||||
|
};
|
||||||
|
show();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- calendar
|
// ---------------------------------------------------------------- calendar
|
||||||
const CAL_COLORS = ["var(--d-accent)", "var(--ok)", "var(--warn)", "#af52de", "#ff2d55", "#5ac8fa"];
|
const CAL_COLORS = ["var(--d-accent)", "var(--ok)", "var(--warn)", "#af52de", "#ff2d55", "#5ac8fa"];
|
||||||
const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user