feat: Modernize the visual card editor. (#2586)

This commit is contained in:
Dermot Duffy
2026-07-20 20:44:16 -07:00
committed by GitHub
parent e204ec183f
commit b6e1de5999
146 changed files with 8487 additions and 5992 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ menu:
enabled: true enabled: true
inert: false inert: false
alignment: matching alignment: matching
icon: iris icon: advanced-camera-card:iris
live: live:
priority: 50 priority: 50
enabled: true enabled: true
+1 -45
View File
@@ -1,45 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4.876 22.744c0.087 0.693 0.812 1.164 0.379 1.271-0.433 0.108-2.327-3.437-2.76-4.952-0.433-1.515-0.676-3.22-0.569-4.194 0.108-0.974 5.71-2.49 5.71-2.895 0-0.406-1.813-0.027-1.894-0.352-0.081-0.325 1.369-0.13 1.759-0.65 0.609-0.812 1.38-0.676 1.867-1.109 0.487-0.433 1.082-2.084 1.191-2.787 0.108-0.704-1.082-1.515-1.408-2.057-0.325-0.541 0.704-0.92 3.194-1.948 2.49-1.028 10.391-3.301 10.499-3.031 0.108 0.271-2.598 1.407-4.492 2.111-1.894 0.704-4.979 2.057-4.979 2.435 0 0.379 0.162 0.677 0.054 1.543-0.108 0.866-0.121 1.27-0.325 1.84-0.135 0.379-0.271 1.272 0 2.083 0.216 0.649 0.92 1.137 1.245 1.245 0.271 0.018 1.017 0.043 1.84 0 1.028-0.054 2.49 0.054 2.49 0.325 0 0.271-2.219 0.108-2.219 0.487 0 0.379 2.273-0.054 2.219 0.325-0.054 0.379-1.894 0.216-2.869 0.216-0.779 0-1.93-0.036-2.435-0.216l-0.108 0.378c-0.18 0-0.557 0.108-0.948 0.109-0.487 0-0.514 0.162-1.407 0.162-0.893 0-0.622-0.054-1.434-0.108-0.812-0.054-3.843 2.273-4.384 3.031-0.541 0.758-0.216 2.598-0.216 3.436 0 0.754-0.108 2.435 0 3.302z"/></svg>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
version="1.1"
id="svg54"
sodipodi:docname="frigate.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs58" />
<sodipodi:namedview
id="namedview56"
pagecolor="#da4040"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
showgrid="false"
width="24px"
inkscape:zoom="45.254834"
inkscape:cx="-0.03314563"
inkscape:cy="9.9768347"
inkscape:window-width="3840"
inkscape:window-height="1527"
inkscape:window-x="1080"
inkscape:window-y="227"
inkscape:window-maximized="1"
inkscape:current-layer="svg54"
showguides="true"
inkscape:showpageshadow="2"
inkscape:deskcolor="#d1d1d1" />
<path
d="m 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 -0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 -0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 -4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 -1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 -0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 -0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 -0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z"
fill="#000000"
id="path330"
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="csccscccccsscscccccsscsccssccscc" />
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -49
View File
@@ -1,49 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.73 15L9.83 21.76C10.53 21.91 11.25 22 12 22c2.4 0 4.6-0.85 6.32-2.25L14.66 13.4M2.46 15c0.92 2.92 3.15 5.26 5.99 6.34L12.12 15m-3.58-3l-3.9-6.75c-1.64 1.75-2.64 4.14-2.64 6.75 0 0.68 0.07 1.35 0.2 2h7.49M21.8 10H14.31L14.6 10.5 19.36 18.75C21 16.97 22 14.6 22 12 22 11.31 21.93 10.64 21.8 10m-0.26-1C20.62 6.07 18.39 3.74 15.55 2.66L11.88 9M9.4 10.5L14.17 2.24c-0.7-0.15-1.42-0.24-2.17-0.24-2.4 0-4.6 0.84-6.32 2.25l3.66 6.35z"/></svg>
<svg
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
sodipodi:docname="camera-iris-transparent.svg"
id="svg4"
version="1.1"
viewBox="0 0 24 24"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview6"
pagecolor="#b93e3e"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.60784314"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="19.378578"
inkscape:cx="12.100991"
inkscape:cy="12.307405"
inkscape:window-width="1440"
inkscape:window-height="848"
inkscape:window-x="0"
inkscape:window-y="52"
inkscape:window-maximized="0"
inkscape:current-layer="svg4" />
<g
id="g1119"
style="fill-opacity:0.05;fill:#ffffff;stroke-width:1.0015748;stroke-dasharray:none">
<circle
style="display:none;opacity:0.5;fill:#000000;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
id="path170"
cx="12"
cy="12"
inkscape:label="Background"
r="11.25" />
<path
d="M 13.730001,15 9.8300003,21.76 C 10.53,21.91 11.25,22 12,22 c 2.400001,0 4.6,-0.85 6.32,-2.25 L 14.660001,13.4 M 2.4600003,15 c 0.92,2.92 3.15,5.26 5.99,6.34 L 12.12,15 m -3.5799997,-3 -3.9,-6.7499996 c -1.64,1.749999 -2.64,4.1399993 -2.64,6.7499996 0,0.68 0.07,1.35 0.2,2 h 7.49 M 21.8,9.9999997 H 14.310001 L 14.600001,10.5 19.36,18.75 C 21,16.97 22,14.6 22,12 22,11.31 21.93,10.64 21.8,9.9999997 m -0.26,-1 C 20.62,6.0700005 18.39,3.7400002 15.550001,2.6600002 L 11.88,8.9999997 M 9.4000003,10.5 14.170001,2.2400002 c -0.7,-0.15 -1.420001,-0.24 -2.170001,-0.24 -2.3999997,0 -4.5999997,0.84 -6.3199997,2.2500003 l 3.66,6.3499995 z"
id="path2"
inkscape:label="Iris"
style="fill-opacity:1;fill:#ffffff;opacity:1;stroke-width:1.0015748;stroke-dasharray:none" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 524 B

+1 -259
View File
@@ -1,259 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3.794 4.615C2.036 6.569 0.97 9.124 0.97 11.959 0.97 18.05 5.909 22.989 12 22.989 18.091 22.989 23.03 18.05 23.03 11.959c0-2.825-1.064-5.393-2.811-7.344-0.835 0.307-1.668 0.664-2.492 1.127 1.595 0.462 2.757 1.939 2.757 3.672 0 2.098-1.708 3.818-3.818 3.818-1.789 0-3.294-1.236-3.712-2.89h-1.908c-0.414 1.655-1.922 2.89-3.712 2.89-2.11 0-3.831-1.72-3.831-3.818 0-1.753 1.21-3.243 2.836-3.686-0.825-0.449-1.676-0.798-2.545-1.113zM12 11.535c0.262 0.976 0.543 1.944 1.697 2.545L12 16.625 10.303 14.08c1.011-0.716 1.478-1.594 1.697-2.545z M7.334 8.141c-0.703 0-1.273 0.57-1.273 1.273 0 0.703 0.57 1.273 1.273 1.272 0.703 0 1.273-0.57 1.272-1.272 0-0.703-0.57-1.273-1.272-1.273z M16.666 8.141c-0.703 0-1.273 0.57-1.272 1.273 0 0.703 0.57 1.273 1.272 1.272 0.703 0 1.273-0.57 1.273-1.272 0-0.703-0.57-1.273-1.273-1.273z M12 0.93c-2.233 0-4.308 0.715-6.045 1.855 1.878 0.201 3.731 0.686 5.833 1.724 2.127-1.084 4.307-1.536 6.27-1.763C16.318 1.598 14.24 0.93 12 0.93Z M19.212 3.302C17.001 3.317 13.981 4.403 11.788 5.406 9.482 4.573 7.116 3.253 4.444 3.316 4.266 3.32 4.095 3.329 3.913 3.342L1.819 3.515 3.807 4.177c2.728 0.889 5.127 1.456 7.239 5.12 0.49 0 1.453 0 1.908 0 2.103-3.643 4.626-4.251 7.225-5.12L22.141 3.541 20.1 3.342c-0.278-0.026-0.572-0.042-0.888-0.04z"/></svg>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
id="svg2"
version="1.1"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
width="24"
height="24"
xml:space="preserve"
sodipodi:docname="motioneye.svg"
inkscape:export-filename="/home/ccrisan/projects/motioneye/static/img/motioneye-logo.png"
inkscape:export-xdpi="960"
inkscape:export-ydpi="960"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs6"><linearGradient
inkscape:collect="always"
id="linearGradient2058"><stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop2054" /><stop
style="stop-color:#585858;stop-opacity:1"
offset="1"
id="stop2056" /></linearGradient><linearGradient
inkscape:collect="always"
id="linearGradient2005"><stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop2001" /><stop
style="stop-color:#808080;stop-opacity:1;"
offset="1"
id="stop2003" /></linearGradient><linearGradient
inkscape:collect="always"
id="linearGradient4205"><stop
style="stop-color:#4aa3e0;stop-opacity:1"
offset="0"
id="stop4207" /><stop
style="stop-color:#3096db;stop-opacity:1"
offset="1"
id="stop4209" /></linearGradient><linearGradient
inkscape:collect="always"
id="linearGradient4197"><stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop4199" /><stop
style="stop-color:#585858;stop-opacity:1"
offset="1"
id="stop4201" /></linearGradient><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4205"
id="linearGradient4211"
x1="26.602072"
y1="43.034946"
x2="26.602072"
y2="29.466328"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.96428571,0,0,0.96428571,0.91428571,0.91428571)" /><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter4285"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4287" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4289" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4291" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4293" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4295" /></filter><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2005"
id="linearGradient4309"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4197"
id="linearGradient4311"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2058"
id="linearGradient4313"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter4315"
x="-0.03618178"
y="-0.20783284"
width="1.0723636"
height="1.3304883"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4317" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4319" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4321" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4323" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4325" /></filter><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter4327"
x="-0.27857143"
y="-0.4720238"
width="1.5571429"
height="1.7505952"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4329" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4331" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4333" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4335" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4337" /></filter><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter4339"
x="-0.0202507"
y="-0.13980794"
width="1.0405014"
height="1.2082853"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4341" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4343" /><feGaussianBlur
in="composite1"
stdDeviation="0.2"
result="blur"
id="feGaussianBlur4345" /><feOffset
dx="0"
dy="-0.5"
result="offset"
id="feOffset4347" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4349" /></filter></defs><sodipodi:namedview
pagecolor="#ff0d0d"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="3840"
inkscape:window-height="1527"
id="namedview4"
showgrid="false"
inkscape:zoom="29.890116"
inkscape:cx="19.973158"
inkscape:cy="16.577386"
inkscape:window-x="1080"
inkscape:window-y="227"
inkscape:window-maximized="1"
inkscape:current-layer="g10"
showguides="true"
inkscape:guide-bbox="true"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#d1d1d1" /><g
id="g10"
inkscape:groupmode="layer"
inkscape:label="ink_ext_XXXXXX"
transform="matrix(1.25,0,0,-1.25,0,64)"><g
id="g4823"
transform="matrix(1.0344694,0,0,1.0344694,-3.551886,-0.86695248)"><path
id="path4244"
d="M 8.9346154,40.515385 C 5.3647588,36.547307 3.2,31.357779 3.2,25.6 3.2,13.228821 13.228821,3.2 25.6,3.2 37.971179,3.2 48,13.228821 48,25.6 c 0,5.736682 -2.161128,10.952493 -5.707692,14.915385 -1.695935,-0.623286 -3.387833,-1.349065 -5.061539,-2.288462 3.2394,-0.937363 5.6,-3.937988 5.6,-7.457692 0,-4.260339 -3.469626,-7.753846 -7.753846,-7.753846 -3.633936,0 -6.690552,2.51055 -7.538461,5.869231 h -3.876924 c -0.840685,-3.360193 -3.903443,-5.869231 -7.538461,-5.869231 -4.284219,0 -7.7807693,3.493507 -7.7807693,7.753846 0,3.56112 2.4570323,6.5856 5.7615383,7.484616 -1.676267,0.912203 -3.404813,1.620556 -5.1692306,2.261538 z M 25.6,26.461538 c 0.532632,-1.981435 1.101793,-3.947553 3.446154,-5.16923 L 25.6,16.123077 22.153846,21.292308 c 2.053593,1.454966 3.000771,3.237758 3.446154,5.16923 z"
style="fill:url(#linearGradient4309);fill-opacity:1;stroke:none;filter:url(#filter4327)"
inkscape:connector-curvature="0"
transform="matrix(0.38078533,0,0,0.38078533,2.9655495,31.335499)" /><path
id="path4242"
d="m 16.123077,33.353847 c -1.427443,0 -2.584616,-1.157173 -2.584616,-2.584616 0,-1.427444 1.157173,-2.584615 2.584616,-2.584615 1.427444,0 2.584615,1.157171 2.584615,2.584615 0,1.427443 -1.157171,2.584616 -2.584615,2.584616 z"
style="fill:url(#linearGradient4311);fill-opacity:1;stroke:none;filter:url(#filter4327)"
inkscape:connector-curvature="0"
transform="matrix(0.38078533,0,0,0.38078533,2.9655495,31.335499)" /><path
id="path4240"
d="m 35.076923,33.353847 c -1.427443,0 -2.584615,-1.157173 -2.584615,-2.584616 0,-1.427444 1.157172,-2.584615 2.584615,-2.584615 1.427443,0 2.584616,1.157171 2.584616,2.584615 0,1.427443 -1.157173,2.584616 -2.584616,2.584616 z"
style="fill:url(#linearGradient4313);fill-opacity:1;stroke:none;filter:url(#filter4327)"
inkscape:connector-curvature="0"
transform="matrix(0.38078533,0,0,0.38078533,2.9655495,31.335499)" /><path
inkscape:connector-curvature="0"
style="fill:#d9d9d9;fill-opacity:1;stroke:none;filter:url(#filter4339)"
d="m 25.6,47.2 c -4.373944,0 -8.437159,-1.399808 -11.838461,-3.634616 3.677605,-0.394237 7.305921,-1.342945 11.423077,-3.375 4.166157,2.122533 8.434154,3.008875 12.279808,3.452886 C 34.057131,45.890032 29.986674,47.2 25.6,47.2 Z"
id="path4248"
transform="matrix(0.39488848,0,0,0.39488848,2.6045087,30.974457)" /><path
inkscape:connector-curvature="0"
style="fill:url(#linearGradient4211);fill-opacity:1;stroke:none;filter:url(#filter4315)"
d="M 39.723077,42.552884 C 35.394064,42.5242 29.479588,40.397223 25.184616,38.432418 20.668821,40.064102 16.035448,42.649343 10.801923,42.526924 10.453022,42.51873 10.118061,42.50105 9.7634616,42.475 L 5.6615384,42.1375 9.5557693,40.839424 c 5.3417977,-1.74056 10.0398397,-2.851302 14.1749997,-10.025963 0.959101,0 2.845924,-4.15e-4 3.738462,-4.15e-4 4.11884,7.134039 9.059296,8.324614 14.149039,10.026378 L 45.460577,42.085577 41.4625,42.475 c -0.544847,0.05181 -1.120992,0.08198 -1.739423,0.07788 z"
id="path4246"
sodipodi:nodetypes="cccccccccccc"
transform="matrix(0.39488848,0,0,0.39488848,2.6045087,30.974457)" /></g></g></svg>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+1 -53
View File
@@ -1,53 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17.037 23.965C16.765 23.918 16.562 23.836 16.326 23.68 16.197 23.594 14.544 21.953 12.218 19.602 10.077 17.438 8.22 15.583 8.09 15.479 7.604 15.089 7.088 14.869 6.499 14.802 6.314 14.781 5.079 14.763 3.754 14.763l-2.409 0 0.007-7.394v-7.394l6.204 0c3.98 0 6.359 0.015 6.637 0.041 2.331 0.22 4.365 1.499 5.579 3.501 0.686 1.131 1.021 2.278 1.067 3.655 0.063 1.884-0.574 3.66-1.831 5.1-0.756 0.866-1.861 1.63-2.929 2.025l-0.32 0.118-1.951-1.979-1.951-1.98 1.046-0.023c0.576-0.013 1.1-0.037 1.165-0.053 0.596-0.151 0.95-0.314 1.322-0.611 0.644-0.513 1.046-1.244 1.138-2.067 0.168-1.505-0.771-2.87-2.263-3.29L13.95 4.323 9.798 4.311 5.639 4.298v3.069 3.07l1.419 0c1.628 0.001 1.779 0.019 2.31 0.28 0.31 0.152 0.39 0.219 1.082 0.915 3.027 3.042 12.194 12.308 12.205 12.337 0.016 0.044-5.364 0.04-5.618-0.004z"/></svg>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg62"
width="24"
height="24"
viewBox="0 0 24 24"
sodipodi:docname="reolink.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs66" />
<sodipodi:namedview
id="namedview64"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showgrid="true"
inkscape:zoom="36.417984"
inkscape:cx="18.191562"
inkscape:cy="8.6633022"
inkscape:window-width="3840"
inkscape:window-height="1527"
inkscape:window-x="1080"
inkscape:window-y="227"
inkscape:window-maximized="1"
inkscape:current-layer="g68">
<inkscape:grid
type="xygrid"
id="grid1175"
dotted="false"
snapvisiblegridlinesonly="true" />
</sodipodi:namedview>
<g
inkscape:groupmode="layer"
inkscape:label="Reolink Logo"
id="g68">
<path
style="fill:#ffffff;stroke-width:0.0393282;fill-opacity:1"
d="M 17.037254,23.964909 C 16.764634,23.917573 16.561708,23.83617 16.325735,23.67954 16.196719,23.593913 14.543544,21.952905 12.217956,19.602014 10.077426,17.438191 8.2198993,15.582673 8.0901166,15.478642 7.6038998,15.088895 7.0877741,14.869424 6.4986827,14.801908 6.3140805,14.780765 5.079061,14.763305 3.7541964,14.763126 l -2.408845,-4.78e-4 0.00713,-7.3937713 v -7.3935985 l 6.2040046,3.536e-5 c 3.9799342,1.969e-5 6.3590882,0.01465833 6.6366112,0.0408227 2.331195,0.21982024 4.364771,1.49885914 5.578726,3.50085534 0.686043,1.1313823 1.020901,2.2783695 1.067186,3.6554196 0.06335,1.8844953 -0.57426,3.6597378 -1.831469,5.0992438 -0.756457,0.866146 -1.861127,1.62991 -2.928729,2.024919 l -0.319718,0.118287 -1.951287,-1.979284 -1.951286,-1.979278 1.046741,-0.02346 c 0.575706,-0.01291 1.099833,-0.03689 1.164724,-0.0533 0.596068,-0.150649 0.949703,-0.313968 1.322206,-0.6106282 0.643573,-0.5125401 1.046169,-1.2439257 1.137985,-2.0673522 0.167852,-1.505317 -0.771478,-2.8704888 -2.263547,-3.2897068 L 13.950001,4.3234481 9.7981831,4.3106846 5.6392389,4.29801 v 3.0692702 3.0692648 l 1.4185165,7.9e-4 c 1.6275801,8.87e-4 1.7789152,0.01912 2.3105245,0.27984 0.3095679,0.15183 0.389559,0.219499 1.0817441,0.915083 3.027399,3.042274 12.19404,12.30837 12.204589,12.337002 0.0161,0.04373 -5.364393,0.03953 -5.617359,-0.0045 z"
id="path1173"
inkscape:label="R" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 898 B

+1 -5
View File
@@ -1,5 +1 @@
<?xml version="1.0" encoding="UTF-8"?> <svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M155.984 0.844C182.129 0.121 205.944 8.49 225.371 26.156 244.256 44.245 252.535 69.269 253.25 94.938 252.928 122.316 242.482 146.002 223.625 165.688 204.23 184.488 179.742 191.456 153.25 191.125 148.548 191.084 143.845 191.042 139 191 139 173.18 139 155.36 139 137 146.59 136.67 154.18 136.34 162 136 174.007 134.137 182.944 130.452 190.5 120.625 197.347 110.432 199.293 99.095 197.574 87.004 195.503 77.231 191.277 69.067 183.188 63 172.794 56.502 160.79 55.075 148.844 57.691 137.647 61.45 129.313 67.937 123.715 78.402 120.929 84.403 119.903 89.77 119.684 96.395 119.645 97.478 119.607 98.562 119.568 99.679 119.525 101.043 119.482 102.407 119.438 103.813 119.293 108.164 119.149 112.516 119 117 101.18 117 83.36 117 65 117 64.398 83.9 67.548 55.283 91 30 108.41 12.165 131.231 2.14 155.984 0.844Z M0 135C39.93 135 79.86 135 121 135 121 174.93 121 214.86 121 256 71 256 71 256 66 252 63.855 248.851 63.756 246.259 63.773 242.497 63.773 241.396 63.773 240.295 63.773 239.161 63.784 237.979 63.794 236.796 63.805 235.578 63.808 234.363 63.81 233.148 63.813 231.896 63.824 228.014 63.85 224.132 63.875 220.25 63.885 217.618 63.894 214.987 63.902 212.355 63.924 205.904 63.958 199.452 64 193 63.046 193.01 63.046 193.01 62.073 193.02 55.438 193.087 48.803 193.132 42.168 193.165 39.693 193.18 37.219 193.2 34.745 193.226 31.183 193.263 27.621 193.28 24.059 193.293 22.957 193.308 21.856 193.324 20.721 193.34 14.691 193.341 10.289 192.993 5 190-8.497 175.793 0 153.016 0 135Z"/></svg>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="256" height="256">
<path d="M0 0 C26.14434533 -0.72288512 49.9600937 7.64611281 69.38671875 25.3125 C88.2717809 43.40139596 96.55079638 68.42490419 97.265625 94.09375 C96.94352486 121.47226222 86.49734687 145.15852541 67.640625 164.84375 C48.24601431 183.64464812 23.75721126 190.61232158 -2.734375 190.28125 C-7.436875 190.24 -12.139375 190.19875 -16.984375 190.15625 C-16.984375 172.33625 -16.984375 154.51625 -16.984375 136.15625 C-9.394375 135.82625 -1.804375 135.49625 6.015625 135.15625 C18.02265058 133.29309086 26.95933822 129.60855193 34.515625 119.78125 C41.36238612 109.58811688 43.30819935 98.25170438 41.58984375 86.16015625 C39.51844005 76.38770565 35.29278822 68.22349741 27.203125 62.15625 C16.80968654 55.65840316 4.80544663 54.2307569 -7.140625 56.84765625 C-18.33687784 60.60594899 -26.67094881 67.09315849 -32.26953125 77.55859375 C-35.05540172 83.55969825 -36.08102174 88.92660162 -36.30078125 95.55078125 C-36.33888916 96.63464111 -36.37699707 97.71850098 -36.41625977 98.83520508 C-36.45936279 100.1992749 -36.50246582 101.56334473 -36.546875 102.96875 C-36.69125 107.320625 -36.835625 111.6725 -36.984375 116.15625 C-54.804375 116.15625 -72.624375 116.15625 -90.984375 116.15625 C-91.5861874 83.05656809 -88.43663153 54.43892655 -64.984375 29.15625 C-47.57427445 11.32086455 -24.75288588 1.29596261 0 0 Z " fill="#48C8D3" transform="translate(155.984375,0.84375)"/>
<path d="M0 0 C39.93 0 79.86 0 121 0 C121 39.93 121 79.86 121 121 C71 121 71 121 66 117 C63.85529247 113.85132353 63.7556918 111.25918777 63.77294922 107.49707031 C63.77310028 106.39623108 63.77325134 105.29539185 63.77340698 104.16119385 C63.78372955 102.97878113 63.79405212 101.79636841 63.8046875 100.578125 C63.8075174 99.36300232 63.81034729 98.14787964 63.81326294 96.89593506 C63.82446046 93.01385665 63.84956466 89.13200871 63.875 85.25 C63.88503034 82.61849291 63.89415609 79.98698221 63.90234375 77.35546875 C63.92439843 70.90356716 63.95787196 64.45180005 64 58 C63.04612648 58.00999275 63.04612648 58.00999275 62.07298279 58.02018738 C55.4379483 58.08669947 48.80300897 58.13186656 42.16772461 58.16479492 C39.6934016 58.1798556 37.21910518 58.20030825 34.74487305 58.22631836 C31.1827131 58.2628203 27.620907 58.27975404 24.05859375 58.29296875 C22.95728622 58.30845261 21.8559787 58.32393646 20.72129822 58.33988953 C14.69081959 58.34120274 10.28924103 57.9927906 5 55 C-8.49681274 40.79282869 0 18.01550479 0 0 Z " fill="#49C8D3" transform="translate(0,135)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+4 -3
View File
@@ -26,10 +26,11 @@
--cover-heading-font-size-max: 48; --cover-heading-font-size-max: 48;
} }
.svg-icon { .svg-icon {
background-color: rgb(255, 255, 255); border-radius: 4px;
padding: 2px;
vertical-align: middle; vertical-align: middle;
background: repeating-conic-gradient(#80808091 0% 25%, transparent 0% 50%) 50% / background: repeating-conic-gradient(#3a3a3a 0% 25%, #282828 0% 50%) 50% / 4px
4px 4px; 4px;
} }
#__sidebar img { #__sidebar img {
max-width: 48px; max-width: 48px;
+18 -11
View File
@@ -1,14 +1,21 @@
# Custom Icons # Custom Icons
The [`menu`](../configuration/menu.md) and The card registers an `advanced-camera-card` iconset with Home Assistant,
[`status_bar`](../configuration/status-bar.md) both support a small set of providing a small set of builtin icons not found in the traditional [Material
builtin custom items not found in the traditional [Material Design Icons Design Icons (`mdi`)](https://pictogrammers.com/library/mdi/) set. Once the
(`mdi`)](https://pictogrammers.com/library/mdi/) set by setting the relevant `icon` paramter to the following values. card resource is loaded, these icons work in _any_ Home Assistant icon field
(inside or outside this card, e.g. an entity icon) and appear in the Home
Assistant icon picker.
| Icon Name | Resulting Icon | | Icon Name | Resulting Icon |
| ----------- | --------------------------------------------------------------------------- | | -------------------------------- | --------------------------------------------------------------------------- |
| `frigate` | ![Frigate Icon](../images/icons/frigate.svg ':size=24 :class=svg-icon') | | `advanced-camera-card:frigate` | ![Frigate Icon](../images/icons/frigate.svg ':size=24 :class=svg-icon') |
| `iris` | ![Iris Icon](../images/icons/iris.svg ':size=24 :class=svg-icon') | | `advanced-camera-card:iris` | ![Iris Icon](../images/icons/iris.svg ':size=24 :class=svg-icon') |
| `motioneye` | ![motionEye Icon](../images/icons/motioneye.svg ':size=24 :class=svg-icon') | | `advanced-camera-card:motioneye` | ![motionEye Icon](../images/icons/motioneye.svg ':size=24 :class=svg-icon') |
| `reolink` | ![Reolink Icon](../images/icons/reolink.svg ':size=24 :class=svg-icon') | | `advanced-camera-card:reolink` | ![Reolink Icon](../images/icons/reolink.svg ':size=24 :class=svg-icon') |
| `tplink` | ![TPLink Icon](../images/icons/tplink.svg ':size=24 :class=svg-icon') | | `advanced-camera-card:tplink` | ![TPLink Icon](../images/icons/tplink.svg ':size=24 :class=svg-icon') |
?> For backwards compatibility, icon fields within this card (e.g.
[`menu`](../configuration/menu.md) and
[`status_bar`](../configuration/status-bar.md) items) also accept the bare
legacy names (`frigate`, `iris`, `motioneye`, `reolink`, `tplink`).
-1
View File
@@ -84,7 +84,6 @@
"rollup-plugin-git-info": "^1.0.0", "rollup-plugin-git-info": "^1.0.0",
"rollup-plugin-serve": "^1.1.1", "rollup-plugin-serve": "^1.1.1",
"rollup-plugin-styler": "^1.8.0", "rollup-plugin-styler": "^1.8.0",
"rollup-plugin-svgo": "^2.0.0",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"sass": "patch:sass@npm%3A1.77.4#~/.yarn/patches/sass-npm-1.77.4-13b6910aea.patch", "sass": "patch:sass@npm%3A1.77.4#~/.yarn/patches/sass-npm-1.77.4-13b6910aea.patch",
"semantic-release": "^24.1.1", "semantic-release": "^24.1.1",
+5 -2
View File
@@ -8,9 +8,11 @@ import typescript from '@rollup/plugin-typescript';
import gitInfo from 'rollup-plugin-git-info'; import gitInfo from 'rollup-plugin-git-info';
import serve from 'rollup-plugin-serve'; import serve from 'rollup-plugin-serve';
import styles from 'rollup-plugin-styler'; import styles from 'rollup-plugin-styler';
import svgo from 'rollup-plugin-svgo';
import { visualizer } from 'rollup-plugin-visualizer'; import { visualizer } from 'rollup-plugin-visualizer';
import { cleanDist } from './scripts/clean-dist-plugin.js';
import { svgPath } from './scripts/svg-path-plugin.js';
const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1'; const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1';
const dev = watch || process.env.DEV === 'true' || process.env.DEV === '1'; const dev = watch || process.env.DEV === 'true' || process.env.DEV === '1';
@@ -35,6 +37,7 @@ const serveopts = {
* @type {import('rollup').RollupOptions['plugins']} * @type {import('rollup').RollupOptions['plugins']}
*/ */
const plugins = [ const plugins = [
cleanDist(),
gitInfo.default( gitInfo.default(
// Limit git-info to the project's own package.json. Without this it also // Limit git-info to the project's own package.json. Without this it also
// rewrites any dependency's imported package.json into ESM, which then breaks // rewrites any dependency's imported package.json into ESM, which then breaks
@@ -54,7 +57,7 @@ const plugins = [
includePaths: ['./node_modules/'], includePaths: ['./node_modules/'],
}, },
}), }),
svgo(), svgPath(),
image({ exclude: '**/*.svg' }), image({ exclude: '**/*.svg' }),
nodeResolve({ nodeResolve({
browser: true, browser: true,
+32
View File
@@ -0,0 +1,32 @@
import { existsSync, readdirSync, rmSync } from 'node:fs';
/**
* Rollup plugin: deletes prior build artifacts from `dist/` (which otherwise
* accumulate stale hashed chunks that can be served in place of fresh output).
* Deletes only top-level build artifacts by extension (no recursion), so
* anything unexpected in `dist/` survives. Cleans once per process: watch mode
* cleans at startup, not on every incremental rebuild.
*
* @type {() => import('rollup').Plugin}
*/
export const cleanDist = () => {
let cleaned = false;
return {
name: 'clean-dist',
buildStart() {
if (cleaned) {
return;
}
cleaned = true;
const dist = new URL('../dist/', import.meta.url);
if (!existsSync(dist)) {
return;
}
for (const entry of readdirSync(dist, { withFileTypes: true })) {
if (entry.isFile() && /\.js(\.map)?$/.test(entry.name)) {
rmSync(new URL(entry.name, dist));
}
}
},
};
};
+4 -17
View File
@@ -2,22 +2,9 @@
# This script copies icons out of the source tree for documentation. # This script copies icons out of the source tree for documentation.
FRIGATE="./src/camera-manager/frigate/assets/frigate.svg"
IRIS="./src/images/iris.svg"
MOTIONEYE="./src/camera-manager/motioneye/assets/motioneye.svg"
REOLINK="./src/camera-manager/reolink/assets/reolink.svg"
TPLINK="./src/camera-manager/tplink/assets/tplink.svg"
DIR_ICONS="./docs/images/icons" DIR_ICONS="./docs/images/icons"
copy() { for ICON in ./src/images/icons/*.svg; do
PATH="$1" echo "Copying image $ICON to $DIR_ICONS"
echo "Copying image $PATH to $DIR_ICONS" /bin/cp "$ICON" "$DIR_ICONS"
/bin/cp "$PATH" "$DIR_ICONS" done
}
copy "$FRIGATE"
copy "$IRIS"
copy "$MOTIONEYE"
copy "$REOLINK"
copy "$TPLINK"
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
/**
* Rollup/Vite plugin: importing an SVG yields `{ path, viewBox }` extracted at
* build time, the shape a Home Assistant custom iconset serves. The SVG must
* be a single-path icon.
*
* @type {() => import('rollup').Plugin}
*/
export const svgPath = () => ({
name: 'svg-path',
// Vite: run before its builtin asset handling.
enforce: 'pre',
load(id) {
if (!id.endsWith('.svg')) {
return null;
}
const source = readFileSync(id, 'utf-8');
const path = / d="([^"]*)"/.exec(source)?.[1];
const viewBox = /viewBox="([^"]*)"/.exec(source)?.[1];
if (!path || !viewBox) {
throw new Error(`Imported SVG must have a path and viewBox: ${id}`);
}
return `export default ${JSON.stringify({ path, viewBox })};`;
},
});
@@ -1,45 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
version="1.1"
id="svg54"
sodipodi:docname="frigate.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs58" />
<sodipodi:namedview
id="namedview56"
pagecolor="#da4040"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
showgrid="false"
width="24px"
inkscape:zoom="45.254834"
inkscape:cx="-0.03314563"
inkscape:cy="9.9768347"
inkscape:window-width="3840"
inkscape:window-height="1527"
inkscape:window-x="1080"
inkscape:window-y="227"
inkscape:window-maximized="1"
inkscape:current-layer="svg54"
showguides="true"
inkscape:showpageshadow="2"
inkscape:deskcolor="#d1d1d1" />
<path
d="m 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 -0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 -0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 -4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 -1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 -0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 -0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 -0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z"
fill="#000000"
id="path330"
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.1;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
sodipodi:nodetypes="csccscccccsscscccccsscsccssccscc" />
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

@@ -1,259 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
id="svg2"
version="1.1"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
width="24"
height="24"
xml:space="preserve"
sodipodi:docname="motioneye.svg"
inkscape:export-filename="/home/ccrisan/projects/motioneye/static/img/motioneye-logo.png"
inkscape:export-xdpi="960"
inkscape:export-ydpi="960"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs6"><linearGradient
inkscape:collect="always"
id="linearGradient2058"><stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop2054" /><stop
style="stop-color:#585858;stop-opacity:1"
offset="1"
id="stop2056" /></linearGradient><linearGradient
inkscape:collect="always"
id="linearGradient2005"><stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop2001" /><stop
style="stop-color:#808080;stop-opacity:1;"
offset="1"
id="stop2003" /></linearGradient><linearGradient
inkscape:collect="always"
id="linearGradient4205"><stop
style="stop-color:#4aa3e0;stop-opacity:1"
offset="0"
id="stop4207" /><stop
style="stop-color:#3096db;stop-opacity:1"
offset="1"
id="stop4209" /></linearGradient><linearGradient
inkscape:collect="always"
id="linearGradient4197"><stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop4199" /><stop
style="stop-color:#585858;stop-opacity:1"
offset="1"
id="stop4201" /></linearGradient><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4205"
id="linearGradient4211"
x1="26.602072"
y1="43.034946"
x2="26.602072"
y2="29.466328"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.96428571,0,0,0.96428571,0.91428571,0.91428571)" /><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter4285"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4287" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4289" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4291" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4293" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4295" /></filter><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2005"
id="linearGradient4309"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4197"
id="linearGradient4311"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2058"
id="linearGradient4313"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter4315"
x="-0.03618178"
y="-0.20783284"
width="1.0723636"
height="1.3304883"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4317" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4319" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4321" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4323" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4325" /></filter><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter4327"
x="-0.27857143"
y="-0.4720238"
width="1.5571429"
height="1.7505952"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4329" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4331" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4333" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4335" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4337" /></filter><filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter4339"
x="-0.0202507"
y="-0.13980794"
width="1.0405014"
height="1.2082853"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4341" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4343" /><feGaussianBlur
in="composite1"
stdDeviation="0.2"
result="blur"
id="feGaussianBlur4345" /><feOffset
dx="0"
dy="-0.5"
result="offset"
id="feOffset4347" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4349" /></filter></defs><sodipodi:namedview
pagecolor="#ff0d0d"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="3840"
inkscape:window-height="1527"
id="namedview4"
showgrid="false"
inkscape:zoom="29.890116"
inkscape:cx="19.973158"
inkscape:cy="16.577386"
inkscape:window-x="1080"
inkscape:window-y="227"
inkscape:window-maximized="1"
inkscape:current-layer="g10"
showguides="true"
inkscape:guide-bbox="true"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#d1d1d1" /><g
id="g10"
inkscape:groupmode="layer"
inkscape:label="ink_ext_XXXXXX"
transform="matrix(1.25,0,0,-1.25,0,64)"><g
id="g4823"
transform="matrix(1.0344694,0,0,1.0344694,-3.551886,-0.86695248)"><path
id="path4244"
d="M 8.9346154,40.515385 C 5.3647588,36.547307 3.2,31.357779 3.2,25.6 3.2,13.228821 13.228821,3.2 25.6,3.2 37.971179,3.2 48,13.228821 48,25.6 c 0,5.736682 -2.161128,10.952493 -5.707692,14.915385 -1.695935,-0.623286 -3.387833,-1.349065 -5.061539,-2.288462 3.2394,-0.937363 5.6,-3.937988 5.6,-7.457692 0,-4.260339 -3.469626,-7.753846 -7.753846,-7.753846 -3.633936,0 -6.690552,2.51055 -7.538461,5.869231 h -3.876924 c -0.840685,-3.360193 -3.903443,-5.869231 -7.538461,-5.869231 -4.284219,0 -7.7807693,3.493507 -7.7807693,7.753846 0,3.56112 2.4570323,6.5856 5.7615383,7.484616 -1.676267,0.912203 -3.404813,1.620556 -5.1692306,2.261538 z M 25.6,26.461538 c 0.532632,-1.981435 1.101793,-3.947553 3.446154,-5.16923 L 25.6,16.123077 22.153846,21.292308 c 2.053593,1.454966 3.000771,3.237758 3.446154,5.16923 z"
style="fill:url(#linearGradient4309);fill-opacity:1;stroke:none;filter:url(#filter4327)"
inkscape:connector-curvature="0"
transform="matrix(0.38078533,0,0,0.38078533,2.9655495,31.335499)" /><path
id="path4242"
d="m 16.123077,33.353847 c -1.427443,0 -2.584616,-1.157173 -2.584616,-2.584616 0,-1.427444 1.157173,-2.584615 2.584616,-2.584615 1.427444,0 2.584615,1.157171 2.584615,2.584615 0,1.427443 -1.157171,2.584616 -2.584615,2.584616 z"
style="fill:url(#linearGradient4311);fill-opacity:1;stroke:none;filter:url(#filter4327)"
inkscape:connector-curvature="0"
transform="matrix(0.38078533,0,0,0.38078533,2.9655495,31.335499)" /><path
id="path4240"
d="m 35.076923,33.353847 c -1.427443,0 -2.584615,-1.157173 -2.584615,-2.584616 0,-1.427444 1.157172,-2.584615 2.584615,-2.584615 1.427443,0 2.584616,1.157171 2.584616,2.584615 0,1.427443 -1.157173,2.584616 -2.584616,2.584616 z"
style="fill:url(#linearGradient4313);fill-opacity:1;stroke:none;filter:url(#filter4327)"
inkscape:connector-curvature="0"
transform="matrix(0.38078533,0,0,0.38078533,2.9655495,31.335499)" /><path
inkscape:connector-curvature="0"
style="fill:#d9d9d9;fill-opacity:1;stroke:none;filter:url(#filter4339)"
d="m 25.6,47.2 c -4.373944,0 -8.437159,-1.399808 -11.838461,-3.634616 3.677605,-0.394237 7.305921,-1.342945 11.423077,-3.375 4.166157,2.122533 8.434154,3.008875 12.279808,3.452886 C 34.057131,45.890032 29.986674,47.2 25.6,47.2 Z"
id="path4248"
transform="matrix(0.39488848,0,0,0.39488848,2.6045087,30.974457)" /><path
inkscape:connector-curvature="0"
style="fill:url(#linearGradient4211);fill-opacity:1;stroke:none;filter:url(#filter4315)"
d="M 39.723077,42.552884 C 35.394064,42.5242 29.479588,40.397223 25.184616,38.432418 20.668821,40.064102 16.035448,42.649343 10.801923,42.526924 10.453022,42.51873 10.118061,42.50105 9.7634616,42.475 L 5.6615384,42.1375 9.5557693,40.839424 c 5.3417977,-1.74056 10.0398397,-2.851302 14.1749997,-10.025963 0.959101,0 2.845924,-4.15e-4 3.738462,-4.15e-4 4.11884,7.134039 9.059296,8.324614 14.149039,10.026378 L 45.460577,42.085577 41.4625,42.475 c -0.544847,0.05181 -1.120992,0.08198 -1.739423,0.07788 z"
id="path4246"
sodipodi:nodetypes="cccccccccccc"
transform="matrix(0.39488848,0,0,0.39488848,2.6045087,30.974457)" /></g></g></svg>

Before

Width:  |  Height:  |  Size: 11 KiB

@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg62"
width="24"
height="24"
viewBox="0 0 24 24"
sodipodi:docname="reolink.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs66" />
<sodipodi:namedview
id="namedview64"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showgrid="true"
inkscape:zoom="36.417984"
inkscape:cx="18.191562"
inkscape:cy="8.6633022"
inkscape:window-width="3840"
inkscape:window-height="1527"
inkscape:window-x="1080"
inkscape:window-y="227"
inkscape:window-maximized="1"
inkscape:current-layer="g68">
<inkscape:grid
type="xygrid"
id="grid1175"
dotted="false"
snapvisiblegridlinesonly="true" />
</sodipodi:namedview>
<g
inkscape:groupmode="layer"
inkscape:label="Reolink Logo"
id="g68">
<path
style="fill:#ffffff;stroke-width:0.0393282;fill-opacity:1"
d="M 17.037254,23.964909 C 16.764634,23.917573 16.561708,23.83617 16.325735,23.67954 16.196719,23.593913 14.543544,21.952905 12.217956,19.602014 10.077426,17.438191 8.2198993,15.582673 8.0901166,15.478642 7.6038998,15.088895 7.0877741,14.869424 6.4986827,14.801908 6.3140805,14.780765 5.079061,14.763305 3.7541964,14.763126 l -2.408845,-4.78e-4 0.00713,-7.3937713 v -7.3935985 l 6.2040046,3.536e-5 c 3.9799342,1.969e-5 6.3590882,0.01465833 6.6366112,0.0408227 2.331195,0.21982024 4.364771,1.49885914 5.578726,3.50085534 0.686043,1.1313823 1.020901,2.2783695 1.067186,3.6554196 0.06335,1.8844953 -0.57426,3.6597378 -1.831469,5.0992438 -0.756457,0.866146 -1.861127,1.62991 -2.928729,2.024919 l -0.319718,0.118287 -1.951287,-1.979284 -1.951286,-1.979278 1.046741,-0.02346 c 0.575706,-0.01291 1.099833,-0.03689 1.164724,-0.0533 0.596068,-0.150649 0.949703,-0.313968 1.322206,-0.6106282 0.643573,-0.5125401 1.046169,-1.2439257 1.137985,-2.0673522 0.167852,-1.505317 -0.771478,-2.8704888 -2.263547,-3.2897068 L 13.950001,4.3234481 9.7981831,4.3106846 5.6392389,4.29801 v 3.0692702 3.0692648 l 1.4185165,7.9e-4 c 1.6275801,8.87e-4 1.7789152,0.01912 2.3105245,0.27984 0.3095679,0.15183 0.389559,0.219499 1.0817441,0.915083 3.027399,3.042274 12.19404,12.30837 12.204589,12.337002 0.0161,0.04373 -5.364393,0.03953 -5.617359,-0.0045 z"
id="path1173"
inkscape:label="R" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="256" height="256">
<path d="M0 0 C26.14434533 -0.72288512 49.9600937 7.64611281 69.38671875 25.3125 C88.2717809 43.40139596 96.55079638 68.42490419 97.265625 94.09375 C96.94352486 121.47226222 86.49734687 145.15852541 67.640625 164.84375 C48.24601431 183.64464812 23.75721126 190.61232158 -2.734375 190.28125 C-7.436875 190.24 -12.139375 190.19875 -16.984375 190.15625 C-16.984375 172.33625 -16.984375 154.51625 -16.984375 136.15625 C-9.394375 135.82625 -1.804375 135.49625 6.015625 135.15625 C18.02265058 133.29309086 26.95933822 129.60855193 34.515625 119.78125 C41.36238612 109.58811688 43.30819935 98.25170438 41.58984375 86.16015625 C39.51844005 76.38770565 35.29278822 68.22349741 27.203125 62.15625 C16.80968654 55.65840316 4.80544663 54.2307569 -7.140625 56.84765625 C-18.33687784 60.60594899 -26.67094881 67.09315849 -32.26953125 77.55859375 C-35.05540172 83.55969825 -36.08102174 88.92660162 -36.30078125 95.55078125 C-36.33888916 96.63464111 -36.37699707 97.71850098 -36.41625977 98.83520508 C-36.45936279 100.1992749 -36.50246582 101.56334473 -36.546875 102.96875 C-36.69125 107.320625 -36.835625 111.6725 -36.984375 116.15625 C-54.804375 116.15625 -72.624375 116.15625 -90.984375 116.15625 C-91.5861874 83.05656809 -88.43663153 54.43892655 -64.984375 29.15625 C-47.57427445 11.32086455 -24.75288588 1.29596261 0 0 Z " fill="#48C8D3" transform="translate(155.984375,0.84375)"/>
<path d="M0 0 C39.93 0 79.86 0 121 0 C121 39.93 121 79.86 121 121 C71 121 71 121 66 117 C63.85529247 113.85132353 63.7556918 111.25918777 63.77294922 107.49707031 C63.77310028 106.39623108 63.77325134 105.29539185 63.77340698 104.16119385 C63.78372955 102.97878113 63.79405212 101.79636841 63.8046875 100.578125 C63.8075174 99.36300232 63.81034729 98.14787964 63.81326294 96.89593506 C63.82446046 93.01385665 63.84956466 89.13200871 63.875 85.25 C63.88503034 82.61849291 63.89415609 79.98698221 63.90234375 77.35546875 C63.92439843 70.90356716 63.95787196 64.45180005 64 58 C63.04612648 58.00999275 63.04612648 58.00999275 62.07298279 58.02018738 C55.4379483 58.08669947 48.80300897 58.13186656 42.16772461 58.16479492 C39.6934016 58.1798556 37.21910518 58.20030825 34.74487305 58.22631836 C31.1827131 58.2628203 27.620907 58.27975404 24.05859375 58.29296875 C22.95728622 58.30845261 21.8559787 58.32393646 20.72129822 58.33988953 C14.69081959 58.34120274 10.28924103 57.9927906 5 55 C-8.49681274 40.79282869 0 18.01550479 0 0 Z " fill="#49C8D3" transform="translate(0,135)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

@@ -1,7 +1,8 @@
import type { PTZAction } from '../../config/schema/actions/custom/ptz'; import type { PTZAction } from '../../config/schema/actions/custom/ptz';
import type { import {
KeyboardShortcuts, PTZ_KEYBOARD_SHORTCUTS,
PTZKeyboardShortcutName, type KeyboardShortcuts,
type PTZKeyboardShortcutName,
} from '../../config/schema/view'; } from '../../config/schema/view';
import { createPTZMultiAction } from '../../utils/action'; import { createPTZMultiAction } from '../../utils/action';
import type { CardConfigLoaderAPI, TaggedAutomation } from '../types'; import type { CardConfigLoaderAPI, TaggedAutomation } from '../types';
@@ -38,7 +39,6 @@ const ptzKeyboardShortcutToPTZAction = (
case 'ptz_zoom_out': case 'ptz_zoom_out':
return 'zoom_out'; return 'zoom_out';
} }
/* istanbul ignore next: No (current) way to reach this code -- @preserve */
return null; return null;
}; };
@@ -52,14 +52,9 @@ const convertKeyboardShortcutsToAutomations = (
const automations: TaggedAutomation[] = []; const automations: TaggedAutomation[] = [];
for (const name of [ // `ptz_home` has no start/stop action, so it maps to a null action here and
'ptz_down', // is handled separately below.
'ptz_left', for (const name of PTZ_KEYBOARD_SHORTCUTS) {
'ptz_right',
'ptz_up',
'ptz_zoom_in',
'ptz_zoom_out',
] as const) {
const shortcut = shortcuts[name]; const shortcut = shortcuts[name];
const ptzAction = ptzKeyboardShortcutToPTZAction(name); const ptzAction = ptzKeyboardShortcutToPTZAction(name);
if (!shortcut || !ptzAction) { if (!shortcut || !ptzAction) {
+6 -5
View File
@@ -1,10 +1,5 @@
import type { CameraConfig } from '../config/schema/cameras'; import type { CameraConfig } from '../config/schema/cameras';
import type { AdvancedCameraCardConfig } from '../config/schema/types'; import type { AdvancedCameraCardConfig } from '../config/schema/types';
import {
MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA,
MEDIA_PLAYER_SUPPORT_STOP,
MEDIA_PLAYER_SUPPORT_TURN_OFF,
} from '../const';
import type { Entity } from '../ha/registry/entity/types'; import type { Entity } from '../ha/registry/entity/types';
import { supportsFeature } from '../ha/supports-feature'; import { supportsFeature } from '../ha/supports-feature';
import { errorToConsole } from '../utils/basic'; import { errorToConsole } from '../utils/basic';
@@ -12,6 +7,12 @@ import type { ViewMedia } from '../view/item';
import { ViewItemClassifier } from '../view/item-classifier'; import { ViewItemClassifier } from '../view/item-classifier';
import type { CardMediaPlayerAPI } from './types'; import type { CardMediaPlayerAPI } from './types';
// Media player support bitmask flags. Taken from:
// https://github.com/home-assistant/frontend/blob/a759767d794f02527d127802831e68d3caf0cb7a/src/data/media-player.ts#L82
export const MEDIA_PLAYER_SUPPORT_TURN_OFF = 256;
export const MEDIA_PLAYER_SUPPORT_STOP = 4096;
export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
export class MediaPlayerManager { export class MediaPlayerManager {
private _mediaPlayers: string[] = []; private _mediaPlayers: string[] = [];
+3
View File
@@ -54,6 +54,7 @@ import type {
RawAdvancedCameraCardConfig, RawAdvancedCameraCardConfig,
} from './config/types.js'; } from './config/types.js';
import { REPO_URL } from './const.js'; import { REPO_URL } from './const.js';
import { registerCustomIconset } from './ha/custom-icons.js';
import type { HomeAssistant, LovelaceCardEditor } from './ha/types.js'; import type { HomeAssistant, LovelaceCardEditor } from './ha/types.js';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
import cardStyle from './scss/card.scss'; import cardStyle from './scss/card.scss';
@@ -105,6 +106,8 @@ console.info(
getEntitySuggestion: ConfigManager.getEntitySuggestion, getEntitySuggestion: ConfigManager.getEntitySuggestion,
}); });
registerCustomIconset();
// Expose currently-connected card instances on `window.advancedCameraCards` for // Expose currently-connected card instances on `window.advancedCameraCards` for
// console-based debugging and user support. `??=` so a double-loaded card // console-based debugging and user support. `??=` so a double-loaded card
// shares one array (same pattern as `customCards` above). // shares one array (same pattern as `customCards` above).
+13
View File
@@ -0,0 +1,13 @@
# Card editor
![Architecture of the card editor](architecture.svg)
The SVG is also its own editable source: it embeds the draw.io diagram, so open
`architecture.svg` directly in draw.io / diagrams.net to change it.
## Maintaining the diagram
`architecture.svg` is a dual file: the **rendered SVG** (shown above / in
GitHub) plus the **editable mxGraph XML** embedded in its `content="..."`
attribute. Keep them in sync -- never commit an SVG whose embedded XML changed
but whose rendered picture did not, or the image above goes stale.
+245
View File
@@ -0,0 +1,245 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { CONF_CAMERAS, CONF_FOLDERS, CONF_PROFILES } from '../../config/const';
import {
addConfigArrayItem,
copyConfig,
deleteConfigArrayItem,
getConfigValue,
isConfigUpgradeable,
moveConfigArrayItem,
upgradeConfig,
} from '../../config/management';
import { setProfiles } from '../../config/profiles/set-profiles';
import { profilesSchema, type ProfileType } from '../../config/schema/profiles';
import { configDefaults } from '../../config/schema/types';
import type {
RawAdvancedCameraCardConfig,
RawAdvancedCameraCardConfigArray,
} from '../../config/types';
import { fireHASSEvent } from '../../ha/fire-hass-event';
import { sideLoadHomeAssistantElements } from '../../ha/side-load-ha-elements';
import type { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { errorToConsole, isRecord } from '../../utils/basic';
import { getCameraID } from '../../utils/camera';
import { getFolderID } from '../../utils/folder';
import { applyConfigChanges } from './form-data';
import type { FormsInput } from './forms-controller';
import type { EditorIntent } from './intents';
import type { FormRequestOptions } from './schema/registry';
import { getEditorCameraTitle, getEditorFolderTitle } from './titles';
type EditorControllerHost = ReactiveControllerHost & EventTarget;
// A card-state notice the editor shows as a banner above the sections.
interface EditorNotice {
type: 'info' | 'warning';
message: string;
}
// Interpret a raw configuration value as an array of configuration objects.
// Non-object entries become empty objects rather than being dropped, so that
// list indices always match the configuration's own indices (editor edits
// address items by index).
const toConfigArray = (value: unknown): RawAdvancedCameraCardConfigArray =>
Array.isArray(value) ? value.map((entry) => (isRecord(entry) ? entry : {})) : [];
/**
* The editor's view of the card configuration: its contents, the defaults an
* unset field falls back to, and anything about it worth telling the user.
* The sections read the configuration through here and report back what the
* user asked to do; this is the only place that changes it.
*/
export class EditorController implements ReactiveController {
private _host: EditorControllerHost;
// Note: The configuration is not parsed with Zod, so it may be partially or
// completely invalid. A partially valid configuration is more useful than
// none, to allow the user to fix the broken parts.
private _config: RawAdvancedCameraCardConfig | null = null;
// The defaults shown for unset fields; profiles change them.
private _defaults = copyConfig(configDefaults);
private _configUpgradeable = false;
private _initialized = false;
private _hass?: HomeAssistant;
// The parsed configuration profiles (empty when unset or invalid).
private _profiles: ProfileType[] = [];
constructor(host: EditorControllerHost) {
this._host = host;
host.addController(this);
}
public hostConnected(): void {
// No connection-time work.
}
// Load the Home Assistant elements the editor renders. A failure leaves the
// editor with degraded form elements and is retried on the next update; it
// is logged so the cause is at least visible.
public initialize(): void {
if (this._initialized) {
return;
}
sideLoadHomeAssistantElements()
.then(() => {
this._initialized = true;
})
.catch((e) => errorToConsole(e));
}
public setConfig(config: RawAdvancedCameraCardConfig): void {
this._applyConfig(config);
this._host.requestUpdate();
}
// Adopt a configuration and derive everything that depends on it. Used for
// both an externally supplied configuration and an edit made in the editor,
// such that the two cannot diverge.
private _applyConfig(config: RawAdvancedCameraCardConfig): void {
this._config = config;
this._configUpgradeable = isConfigUpgradeable(config);
// The defaults are rebuilt from scratch so that removing (or breaking) a
// profile also removes its adjusted defaults.
const profiles = profilesSchema.safeParse(getConfigValue(config, CONF_PROFILES));
this._profiles = profiles.success ? profiles.data ?? [] : [];
const defaults = copyConfig(configDefaults);
if (profiles.success) {
setProfiles(config, defaults, profiles.data);
}
this._defaults = defaults;
}
public getNotices(): EditorNotice[] {
const notices: EditorNotice[] = [];
if (this._profiles.includes('low-performance')) {
notices.push({
type: 'warning',
message: localize('config.performance.warning'),
});
}
const overrides = this._config?.['overrides'];
if (Array.isArray(overrides) && overrides.length) {
notices.push({ type: 'info', message: localize('config.overrides.info') });
}
return notices;
}
public getConfig(): RawAdvancedCameraCardConfig | null {
return this._config;
}
public getDefaults(): RawAdvancedCameraCardConfig {
return this._defaults;
}
/**
* Get what a section needs to show its forms: the configuration the forms
* display, the defaults an unset field falls back to, and the lists its
* dropdowns choose from (the entities, the cameras a camera may depend on,
* the folders its media may come from).
* @returns The section input.
*/
public getFormsInput(): FormsInput {
return {
config: this._config ?? {},
defaults: this._defaults,
options: this._getFormRequestOptions(),
};
}
private _getFormRequestOptions(): FormRequestOptions {
return {
cameras: this.getCameras().map((camera, index) => ({
value: getCameraID(camera),
label: getEditorCameraTitle(index, camera, this._hass),
})),
folders: this.getFolders().map((folder, index) => ({
value: getFolderID(folder, index),
label: getEditorFolderTitle(index, folder),
})),
};
}
public setHASS(hass: HomeAssistant): void {
this._hass = hass;
}
public getHASS(): HomeAssistant | undefined {
return this._hass;
}
public isConfigUpgradeable(): boolean {
return this._configUpgradeable;
}
public upgrade(): void {
this._modifyConfig(upgradeConfig);
}
public getCameras(): RawAdvancedCameraCardConfigArray {
return toConfigArray(getConfigValue(this._config ?? {}, CONF_CAMERAS));
}
public getFolders(): RawAdvancedCameraCardConfigArray {
return toConfigArray(getConfigValue(this._config ?? {}, CONF_FOLDERS));
}
/**
* Carry out what a section reported the user asked for. Every path an intent
* carries is absolute, so nothing here needs to know which section it came
* from.
* @param intent What the user asked to do.
*/
public applyIntent(intent: EditorIntent): void {
if (!this._config) {
return;
}
switch (intent.type) {
case 'changes': {
const newConfig = applyConfigChanges(this._config, intent.changes);
if (newConfig) {
this._updateConfig(newConfig);
}
return;
}
case 'list-add':
this._modifyConfig((config) =>
addConfigArrayItem(config, intent.path, intent.item),
);
return;
case 'list-move':
this._modifyConfig((config) =>
moveConfigArrayItem(config, intent.path, intent.from, intent.to),
);
return;
case 'list-delete':
this._modifyConfig((config) =>
deleteConfigArrayItem(config, intent.path, intent.index),
);
return;
}
}
private _modifyConfig(func: (config: RawAdvancedCameraCardConfig) => boolean): void {
if (this._config) {
const newConfig = copyConfig(this._config);
if (func(newConfig)) {
this._updateConfig(newConfig);
}
}
}
private _updateConfig(config: RawAdvancedCameraCardConfig): void {
this._applyConfig(config);
this._host.requestUpdate();
fireHASSEvent(this._host, 'config-changed', { config });
}
}
+111
View File
@@ -0,0 +1,111 @@
import { DOCS_URL } from '../../const';
import type { HAFormSchema } from '../../ha/types';
import { getFormContainerPath, stripArrayIndices } from './paths';
// Documentation links keyed by configuration path (without array indices).
// Sections and expandable groups with an entry here render a documentation
// link row.
const DOC_LINKS: Record<string, string> = {
cameras: 'configuration/cameras/README',
'cameras.capabilities': 'configuration/cameras/README?id=capabilities',
'cameras.cast': 'configuration/cameras/README?id=cast',
'cameras.dependencies': 'configuration/cameras/README?id=dependencies',
'cameras.dimensions': 'configuration/cameras/README?id=dimensions',
'cameras.dimensions.layout': 'configuration/cameras/README?id=layout-configuration',
'cameras.engine': 'configuration/cameras/engine',
'cameras.frigate': 'configuration/cameras/engine?id=frigate',
'cameras.go2rtc': 'configuration/cameras/live-provider?id=go2rtc',
'cameras.image': 'configuration/cameras/live-provider?id=image',
'cameras.live_provider': 'configuration/cameras/live-provider',
'cameras.media': 'configuration/cameras/README?id=media',
'cameras.motioneye': 'configuration/cameras/engine?id=motioneye',
'cameras.proxy': 'configuration/cameras/README?id=proxy',
'cameras.reolink': 'configuration/cameras/engine?id=reolink',
'cameras.triggers': 'configuration/cameras/README?id=triggers',
'cameras.triggers.reviews': 'configuration/cameras/README?id=reviews',
'cameras.webrtc_card': 'configuration/cameras/live-provider?id=webrtc_card',
dimensions: 'configuration/dimensions',
folders: 'configuration/folders',
'folders.ha': 'configuration/folders?id=ha',
image: 'configuration/image',
'image.proxy': 'configuration/image?id=proxy',
live: 'configuration/live',
'live.controls': 'configuration/live?id=controls',
'live.controls.call': 'configuration/live?id=call',
'live.controls.next_previous': 'configuration/live?id=next_previous',
'live.controls.ptz': 'configuration/live?id=ptz',
'live.controls.thumbnails': 'configuration/live?id=thumbnails',
'live.controls.timeline': 'configuration/live?id=timeline',
'live.controls.timeline.format': 'configuration/live?id=format',
'live.display': 'configuration/live?id=display',
'live.microphone': 'configuration/live?id=microphone',
media_gallery: 'configuration/media-gallery',
'media_gallery.controls.filter': 'configuration/media-gallery?id=filter',
'media_gallery.controls.thumbnails': 'configuration/media-gallery?id=thumbnails',
media_viewer: 'configuration/media-viewer',
'media_viewer.controls': 'configuration/media-viewer?id=controls',
'media_viewer.controls.next_previous': 'configuration/media-viewer?id=next_previous',
'media_viewer.controls.thumbnails': 'configuration/media-viewer?id=thumbnails',
'media_viewer.controls.timeline': 'configuration/media-viewer?id=timeline',
'media_viewer.controls.timeline.format': 'configuration/media-viewer?id=format',
'media_viewer.display': 'configuration/media-viewer?id=display',
menu: 'configuration/menu',
'menu.buttons': 'configuration/menu?id=buttons',
options: 'configuration/README',
performance: 'configuration/performance',
'performance.features': 'configuration/performance?id=features',
'performance.style': 'configuration/performance?id=style',
profiles: 'configuration/profiles',
remote_control: 'configuration/remote-control',
'remote_control.entities': 'configuration/remote-control?id=entities',
status_bar: 'configuration/status-bar',
'status_bar.items': 'configuration/status-bar?id=items',
timeline: 'configuration/timeline',
'timeline.controls.thumbnails': 'configuration/timeline?id=thumbnails',
'timeline.format': 'configuration/timeline?id=format',
view: 'configuration/view',
'view.default_reset': 'configuration/view?id=default_reset',
'view.issues': 'configuration/view?id=issues',
'view.keyboard_shortcuts': 'configuration/view?id=keyboard_shortcuts',
'view.triggers': 'configuration/view?id=triggers',
'view.triggers.actions': 'configuration/view?id=trigger-action-configuration',
};
/**
* Get the documentation URL for a configuration path.
* @param path The configuration path segments (array indices are ignored).
* @returns A full documentation URL, or null if the path has no documentation
* link.
*/
export const getDocURL = (path: (string | number)[]): string | null => {
const docPath = DOC_LINKS[stripArrayIndices(path).join('.')];
return docPath ? `${DOCS_URL}/#/${docPath}` : null;
};
/**
* Get the documentation-link path for a form node.
* @param basePath The path of the form's data within the configuration.
* @param schema The node's schema.
* @param options Path context provided by `ha-form` for nested containers.
* @returns The configuration path to link to, or null for no link.
*/
export const getDocLinkPath = (
basePath: (string | number)[],
schema: HAFormSchema,
options?: { path?: string[] },
): (string | number)[] | null => {
// Documentation links attach to containers (sections/groups), never to
// individual fields.
if ('selector' in schema) {
return null;
}
// A nameless group (an editor-only grouping such as "Engines") carries an
// explicit `docPath`; a named group derives its path from the configuration
// path.
return schema.docPath
? schema.docPath.split('.')
: schema.name
? [...basePath, ...getFormContainerPath(options), schema.name]
: null;
};
+194
View File
@@ -0,0 +1,194 @@
import { isEqual } from 'lodash-es';
import {
copyConfig,
deleteConfigValue,
getConfigValue,
setConfigValue,
} from '../../config/management';
import type { RawAdvancedCameraCardConfig } from '../../config/types';
import type { HAFormSchema, HAFormSelectorSchema } from '../../ha/types';
import { isRecord } from '../../utils/basic';
import {
isComputedFieldBinding,
type ConfigChange,
type ConfigPath,
type EditorForm,
type FieldBinding,
} from './types';
/**
* Call a callback for every selector-based (leaf) field in a form schema, with
* the field's path relative to the form data (named expandable containers nest
* their contents).
* @param schema The form schema.
* @param callback The callback to call with the relative path and field.
*/
export const forEachFieldRecursively = (
schema: readonly HAFormSchema[],
callback: (path: string[], field: HAFormSelectorSchema) => void,
): void => {
const recurse = (items: readonly HAFormSchema[], prefix: string[]): void => {
for (const item of items) {
if ('selector' in item) {
callback([...prefix, item.name], item);
} else {
// A nameless expandable is a visual-only grouping, so it is not added
// to the path.
recurse(item.schema, !item.name ? prefix : [...prefix, item.name]);
}
}
};
recurse(schema, []);
};
const findBinding = (form: EditorForm, formPath: string[]): FieldBinding | undefined =>
form.bindings?.find((binding) => isEqual(binding.formPath, formPath));
// The value to show for a bound field. A computed binding works its own value
// out; otherwise the configured value is shown, and only a missing value falls
// back to the default. A field set to null is shown as null.
const readBoundFieldValue = (
binding: FieldBinding,
config: RawAdvancedCameraCardConfig,
defaults: RawAdvancedCameraCardConfig,
): unknown => {
if (isComputedFieldBinding(binding)) {
return binding.read(config, defaults);
}
const configured = getConfigValue(config, binding.configPath);
return configured === undefined
? getConfigValue(defaults, binding.configPath)
: configured;
};
/**
* Compute the data object a form displays: the raw configuration values, with
* each absent field filled in from the (profile-adjusted) configuration
* defaults, so the form shows the effective value of every field.
* @param form The form.
* @param config The whole raw (possibly invalid) configuration.
* @param defaults The whole configuration defaults.
* @returns A new data object for the form to display.
*/
export const computeDisplayedData = (
form: EditorForm,
config: RawAdvancedCameraCardConfig,
defaults: RawAdvancedCameraCardConfig,
): RawAdvancedCameraCardConfig => {
const raw = form.basePath.length ? getConfigValue(config, form.basePath) : config;
const formDefaults = form.basePath.length
? getConfigValue(defaults, form.basePath)
: defaults;
const data: RawAdvancedCameraCardConfig = isRecord(raw) ? copyConfig(raw) : {};
forEachFieldRecursively(form.schema, (path) => {
const binding = findBinding(form, path);
if (binding) {
// A bound field is stored elsewhere in the configuration, so anything
// the copy above holds at this path belongs to a different setting and
// is replaced.
const value = readBoundFieldValue(binding, config, defaults);
setConfigValue(data, path, value === undefined ? undefined : copyConfig(value));
return;
}
if (getConfigValue(data, path) !== undefined) {
return;
}
// The configuration does not set this field, so show its default (looked
// up by path since the defaults object mirrors the configuration shape).
const defaultValue = isRecord(formDefaults)
? getConfigValue(formDefaults, path)
: undefined;
if (defaultValue !== undefined) {
// Copied so no mutation of the data can reach into the defaults.
setConfigValue(data, path, copyConfig(defaultValue));
}
});
return data;
};
/**
* Compute the configuration changes a form edit represents, by comparing the
* data the form emitted against the data it was displaying. Only schema-declared
* leaf fields are compared, so any other content the emitted object carries
* (including display defaults the user did not touch) never produces a change.
* String values are trimmed, and a field the user emptied requests deletion of
* the underlying configuration key. Paths are absolute, so a caller applies
* them without needing to know where the form sits.
* @param form The form.
* @param displayed The data the form was displaying.
* @param emitted The data object the form emitted.
* @param config The whole raw configuration (needed by bound fields).
* @param defaults The whole configuration defaults (likewise).
* @returns The list of configuration changes.
*/
export const computeConfigChanges = (
form: EditorForm,
displayed: RawAdvancedCameraCardConfig,
emitted: unknown,
config: RawAdvancedCameraCardConfig,
defaults: RawAdvancedCameraCardConfig = {},
): ConfigChange[] => {
const changes: ConfigChange[] = [];
if (!isRecord(emitted)) {
return changes;
}
forEachFieldRecursively(form.schema, (path) => {
const before = getConfigValue(displayed, path);
let after = getConfigValue(emitted, path);
if (typeof after === 'string') {
after = after.trim();
}
if (isEqual(before, after)) {
return;
}
const binding = findBinding(form, path);
if (binding && isComputedFieldBinding(binding)) {
changes.push(...binding.write(after, config, defaults));
return;
}
const configPath: ConfigPath = binding
? binding.configPath
: [...form.basePath, ...path];
if (after === undefined || after === '') {
changes.push({ path: configPath, type: 'delete' });
} else {
changes.push({ path: configPath, type: 'set', value: after });
}
});
return changes;
};
/**
* Apply configuration changes to a raw configuration. Changes are applied onto
* a copy of the existing configuration, so configuration content the schema
* does not know about is left untouched.
* @param config The raw configuration (not modified).
* @param changes The changes to apply, at absolute paths.
* @returns A new configuration with the changes applied, or null if the
* changes leave the configuration unmodified.
*/
export const applyConfigChanges = (
config: RawAdvancedCameraCardConfig,
changes: readonly ConfigChange[],
): RawAdvancedCameraCardConfig | null => {
if (!changes.length) {
return null;
}
const newConfig = copyConfig(config);
for (const change of changes) {
if (change.type === 'delete') {
deleteConfigValue(newConfig, change.path);
} else {
// Copied so no mutation of the form data can reach into the returned
// configuration.
setConfigValue(newConfig, change.path, copyConfig(change.value));
}
}
return isEqual(newConfig, config) ? null : newConfig;
};
+42
View File
@@ -0,0 +1,42 @@
import type { HAFormSchema } from '../../ha/types';
import { localize } from '../../localize/localize';
import { getFormContainerPath, stripArrayIndices } from './paths';
/**
* Get the localization key for a configuration path.
* @param path The configuration path segments.
* @returns The localization key.
*/
export const getLocalizationKeyForPath = (path: (string | number)[]): string =>
['config', ...stripArrayIndices(path)].join('.');
/**
* Compute the label for a form field. Intended for use as an `ha-form`
* `computeLabel` callback, bound to the path of the form's data within the
* configuration. Container nodes are labelled by their explicit title, never
* by configuration path (their headers already render the title).
* @param basePath The path of the form's data within the configuration.
* @param schema The field's schema.
* @param options Path context provided by `ha-form` for fields nested inside
* containers.
* @returns A localized label.
*/
export const computeFormLabel = (
basePath: (string | number)[],
schema: HAFormSchema,
options?: { path?: string[] },
): string => {
if (schema.label !== undefined) {
return schema.label;
}
if (!('selector' in schema)) {
return schema.title ?? schema.name ?? '';
}
return localize(
getLocalizationKeyForPath([
...basePath,
...getFormContainerPath(options),
schema.name,
]),
);
};
@@ -0,0 +1,209 @@
import type { TemplateResult } from 'lit';
import { isEqual } from 'lodash-es';
import type { RawAdvancedCameraCardConfig } from '../../config/types';
import type { HAFormSchema } from '../../ha/types';
import { getDocLinkPath } from './doc-links';
import { computeConfigChanges, computeDisplayedData } from './form-data';
import { computeFormLabel } from './form-labels';
import { getForms, type FormRequest, type FormRequestOptions } from './schema/registry';
import type { ConfigChange, ConfigPath, EditorForm } from './types';
// A form together with the callbacks `ha-form` needs. Held as one object and
// reused across renders: `ha-form` compares each of these by identity, and
// rebuilding them would re-render every field of every open form whenever
// anything else in the editor changes.
export interface FormContext {
// The fields to show, and where in the configuration they live.
form: EditorForm;
// The values the fields show: the configured ones, with anything unset
// filled in from the defaults.
displayedData: RawAdvancedCameraCardConfig;
// The name to show for a field.
computeLabel: (
schema: HAFormSchema,
data?: unknown,
options?: { path?: string[] },
) => string;
// The documentation link to show under a field, or null for a field that
// has no documentation of its own.
computeHelper: (
schema: HAFormSchema,
options?: { path?: string[] },
) => TemplateResult | null;
// Called by `ha-form` with the whole form's data whenever the user edits a
// field.
valueChanged: (ev: CustomEvent<{ value?: unknown }>) => void;
}
// Everything a form needs to be built and filled in: the user's configuration,
// the defaults a field shows when the configuration does not set it, and the
// lists of cameras and folders the dropdowns choose from.
export interface FormsInput {
// The user's configuration, which may be partially or completely invalid.
config: RawAdvancedCameraCardConfig;
// The value each field shows when the configuration does not set it, with
// any adjustments the configured profiles make.
defaults: RawAdvancedCameraCardConfig;
// The lists a dropdown chooses from: the cameras and the folders.
options: FormRequestOptions;
}
/**
* Builds the forms for one part of the configuration (e.g. the menu section, or
* a camera) and works out what each one displays, what its fields are called,
* where its documentation is, and what an edit to it means for the
* configuration.
*
* The forms are built again only if a different part is asked for, or if the
* lists the dropdowns choose from change. `ha-form` compares what it is given
* by identity, so handing it a new object every render would redraw every field
* and interrupt whatever the user was typing.
*
* This never changes the configuration. An edit is passed to `onChanges` as a
* list of changes, each naming the full path of the value it applies to.
*/
export class FormsController {
// Reports the changes an edit represents. The changes carry absolute
// configuration paths, so the receiver applies them without knowing which
// form produced them.
private _onChanges: (changes: ConfigChange[]) => void;
// Renders the documentation link for a configuration path. Injected because
// a template belongs to the rendering layer.
private _renderDocumentation: (path: ConfigPath) => TemplateResult | null;
private _request: FormRequest | null = null;
private _options: FormRequestOptions | null = null;
// Empty until the first `setInput`, which is harmless: there are no forms to
// read it until then.
private _input: FormsInput = {
config: {},
defaults: {},
options: { cameras: [], folders: [] },
};
private _contexts: FormContext[] = [];
constructor(
onChanges: (changes: ConfigChange[]) => void,
renderDocumentation: (path: ConfigPath) => TemplateResult | null,
) {
this._onChanges = onChanges;
this._renderDocumentation = renderDocumentation;
}
/**
* Set which forms are wanted and the configuration they display. The
* contexts are rebuilt only when something they depend on has actually
* changed, so that a change elsewhere in the editor leaves open forms alone.
* @param request Which part of the configuration the forms are for.
* @param input The configuration, the defaults and the dropdown choices.
*/
public setInput(request: FormRequest, input: FormsInput): void {
// Whether the forms *might* differ. A request builds its forms from only
// some of the options, so this asks the question rather than answering it.
const mightDiffer =
!isEqual(this._request, request) || !isEqual(this._options, input.options);
this._request = request;
this._options = input.options;
// Whether they actually do. Building forms is cheap; replacing them is not,
// since `ha-form` compares by identity and redraws every field it is handed
// anew. Renaming a camera, for instance, changes the list of cameras every
// *other* camera's dependency dropdown offers, while that camera's own form
// (which never lists itself) comes back exactly as it was.
const rebuilt = mightDiffer ? getForms(request, input.options) : null;
const changedForms =
rebuilt &&
!isEqual(
rebuilt,
this._contexts.map((context) => context.form),
)
? rebuilt
: null;
const previous = this._input;
if (
!changedForms &&
previous.config === input.config &&
previous.defaults === input.defaults
) {
return;
}
this._input = input;
// Only what a form displays depends on the configuration. Its schema and
// its callbacks do not, so when the forms themselves are unchanged only the
// displayed data is replaced: rebuilding the rest would have `ha-form`
// re-render every field of every open form, losing whatever the user was
// typing.
this._contexts = changedForms
? changedForms.map((form, index) => this._buildContext(form, index, input))
: this._contexts.map((context) => ({
...context,
displayedData: computeDisplayedData(
context.form,
input.config,
input.defaults,
),
}));
}
public getContexts(): FormContext[] {
return this._contexts;
}
private _buildContext(
form: EditorForm,
index: number,
input: FormsInput,
): FormContext {
return {
form,
displayedData: computeDisplayedData(form, input.config, input.defaults),
computeLabel: (
schema: HAFormSchema,
_data?: unknown,
options?: { path?: string[] },
) => computeFormLabel(form.basePath, schema, options),
// Caution: `ha-form` types the helper as a string, but interpolates it
// into a Lit template, so a template renders as-is; should a future Home
// Assistant version coerce it to a string, the link would degrade to
// text.
computeHelper: (schema: HAFormSchema, options?: { path?: string[] }) => {
const path = getDocLinkPath(form.basePath, schema, options);
return path ? this._renderDocumentation(path) : null;
},
valueChanged: (ev: CustomEvent<{ value?: unknown }>) =>
this._valueChanged(index, ev),
};
}
private _valueChanged(index: number, ev: CustomEvent<{ value?: unknown }>): void {
const context = this._contexts[index];
if (!context) {
return;
}
const changes = computeConfigChanges(
context.form,
context.displayedData,
ev.detail?.value,
this._input.config,
this._input.defaults,
);
if (changes.length) {
this._onChanges(changes);
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
import type { ConfigChange, ConfigPath } from './types';
// Something the user has asked to do to the configuration, addressed
// absolutely. A section reports these rather than editing anything itself: the
// editor holds the configuration and is the only thing that writes to it.
export type EditorIntent =
| { type: 'changes'; changes: ConfigChange[] }
| { type: 'list-add'; path: ConfigPath; item: unknown }
| { type: 'list-move'; path: ConfigPath; from: number; to: number }
| { type: 'list-delete'; path: ConfigPath; index: number };
const EDITOR_INTENT_EVENT = 'editor:intent';
/**
* Report an intent to the editor. The event travels up out of the reporting
* component's shadow root, and only the editor listens for it: nothing in
* between interprets it, and the paths it carries are absolute, so it means
* the same wherever it is seen.
* @param target The element reporting the intent.
* @param intent What the user asked to do.
*/
export const fireEditorIntent = (target: EventTarget, intent: EditorIntent): void => {
fireAdvancedCameraCardEvent(target, EDITOR_INTENT_EVENT, intent, {
bubbles: true,
composed: true,
});
};
@@ -0,0 +1,86 @@
import type { ReactiveController, ReactiveControllerHost, TemplateResult } from 'lit';
import { CONF_VIEW_KEYBOARD_SHORTCUTS } from '../../config/const';
import { getConfigValue } from '../../config/management';
import { PTZ_KEYBOARD_SHORTCUTS, type KeyboardShortcut } from '../../config/schema/view';
import { FormsController, type FormContext, type FormsInput } from './forms-controller';
import { fireEditorIntent } from './intents';
import type { FormRequest } from './schema/registry';
import type { ConfigChange, ConfigPath } from './types';
type KeyboardShortcutsControllerHost = ReactiveControllerHost & EventTarget;
const FORM_REQUEST: FormRequest = { kind: 'section', name: 'view.keyboard_shortcuts' };
/**
* The keyboard shortcuts panel: the settings that apply to all the shortcuts,
* and the key assigned to each one. A key is assigned by pressing it rather
* than by filling in a field, so the individual shortcuts cannot be part of
* the panel's own form.
*/
export class KeyboardShortcutsController implements ReactiveController {
private _host: KeyboardShortcutsControllerHost;
private _forms: FormsController;
private _input: FormsInput | null = null;
constructor(
host: KeyboardShortcutsControllerHost,
renderDocumentation: (path: ConfigPath) => TemplateResult | null,
) {
this._host = host;
this._forms = new FormsController(
(changes: ConfigChange[]) =>
fireEditorIntent(this._host, { type: 'changes', changes }),
renderDocumentation,
);
host.addController(this);
}
public hostConnected(): void {
// No connection-time work.
}
public setInput(input: FormsInput): void {
this._input = input;
this._forms.setInput(FORM_REQUEST, input);
}
public getContexts(): FormContext[] {
return this._forms.getContexts();
}
/**
* Get the key assigned to each shortcut: the configured one, or the default
* where the configuration says nothing.
* @returns The shortcuts, keyed by name and in the order they are shown.
*/
public getShortcuts(): Record<string, unknown> {
const shortcuts: Record<string, unknown> = {};
for (const name of PTZ_KEYBOARD_SHORTCUTS) {
shortcuts[name] = getConfigValue(
this._input?.config ?? {},
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.${name}`,
getConfigValue(this._input?.defaults ?? {}, [
...CONF_VIEW_KEYBOARD_SHORTCUTS.split('.'),
name,
]),
);
}
return shortcuts;
}
public setShortcut(name: string, value: KeyboardShortcut | null): void {
fireEditorIntent(this._host, {
type: 'changes',
changes: [
{
path: [...CONF_VIEW_KEYBOARD_SHORTCUTS.split('.'), name],
// A null shortcut is set rather than deleted: it means the user
// deliberately unassigned a key that has a default.
type: 'set',
value,
},
],
});
}
}
@@ -0,0 +1,97 @@
import type { ReactiveController, ReactiveControllerHost, TemplateResult } from 'lit';
import { EqualityMap } from '../../cache/equality-map';
import { getConfigValue } from '../../config/management';
import type { RawAdvancedCameraCardConfigArray } from '../../config/types';
import { isRecord } from '../../utils/basic';
import { FormsController, type FormContext, type FormsInput } from './forms-controller';
import { fireEditorIntent } from './intents';
import type { FormRequest } from './schema/registry';
import type { ConfigChange, ConfigPath } from './types';
type ListFormsControllerHost = ReactiveControllerHost & EventTarget;
/**
* The forms for a part of the editor that shows a list the user can add to,
* reorder and delete from (e.g. cameras, folders, a camera's event triggers).
* Every item in such a list has its own forms, so the forms are kept one set
* per item rather than under fixed names, and the list itself is read straight
* from the configuration, where it is an array.
*
* This never changes the configuration. An edit, or a change to the list, is
* reported to the editor as an intent naming the full path it applies to.
*/
export class ListFormsController implements ReactiveController {
private _host: ListFormsControllerHost;
private _renderDocumentation: (path: ConfigPath) => TemplateResult | null;
private _input: FormsInput | null = null;
// One set of forms per item asked for, kept between renders so that editing
// one item does not redraw the others. Keyed by the request itself, matched
// by value.
private _formsControllers = new EqualityMap<FormRequest, FormsController>();
constructor(
host: ListFormsControllerHost,
renderDocumentation: (path: ConfigPath) => TemplateResult | null,
) {
this._host = host;
this._renderDocumentation = renderDocumentation;
host.addController(this);
}
public hostConnected(): void {
// No connection-time work.
}
public setInput(input: FormsInput): void {
this._input = input;
}
/**
* Read a list of items from the configuration. Non-object entries become
* empty objects rather than being dropped, so that positions in the list
* always match the configuration's own indices (edits address items by
* index).
* @param path The path of the list.
* @returns The items.
*/
public getList(path: ConfigPath): RawAdvancedCameraCardConfigArray {
const value = this._input ? getConfigValue(this._input.config, path) : null;
return Array.isArray(value)
? value.map((entry) => (isRecord(entry) ? entry : {}))
: [];
}
public getFormContexts(request: FormRequest): FormContext[] {
const input = this._input;
if (!input) {
return [];
}
let formsController = this._formsControllers.get(request);
if (!formsController) {
formsController = new FormsController(
(changes: ConfigChange[]) =>
fireEditorIntent(this._host, { type: 'changes', changes }),
this._renderDocumentation,
);
this._formsControllers.set(request, formsController);
}
formsController.setInput(request, input);
return formsController.getContexts();
}
public addItem(path: ConfigPath, item: unknown): void {
fireEditorIntent(this._host, { type: 'list-add', path, item });
}
public moveItem(path: ConfigPath, from: number, to: number): void {
fireEditorIntent(this._host, { type: 'list-move', path, from, to });
}
public deleteItem(path: ConfigPath, index: number): void {
fireEditorIntent(this._host, { type: 'list-delete', path, index });
}
}
@@ -0,0 +1,48 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
// One step of the path into the editor: which list was drilled into, and the
// position within it of the item opened.
interface ListPage {
list: string;
index: number;
}
/**
* The path of list items the user has drilled into. An empty path is the top
* list itself; each further step names the list entered and the item's position
* in it, so `[{list: 'cameras', index: 2}, {list: 'events', index: 0}]` is the
* first event of the third camera.
*
* Only one step is shown at a time, so opening an item replaces the list rather
* than expanding within it.
*/
export class ListPagesController implements ReactiveController {
private _host: ReactiveControllerHost;
private _path: ListPage[] = [];
constructor(host: ReactiveControllerHost) {
this._host = host;
host.addController(this);
}
public hostConnected(): void {
// No connection-time work.
}
public getPath(): readonly ListPage[] {
return this._path;
}
public open(list: string, index: number): void {
this._path = [...this._path, { list, index }];
this._host.requestUpdate();
}
public back(): void {
if (!this._path.length) {
return;
}
this._path = this._path.slice(0, -1);
this._host.requestUpdate();
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Convert the container path `ha-form` passes to `computeLabel`/`computeHelper`
* into configuration order. `ha-form` builds the path by appending each
* enclosing group's name as the call climbs outward, so the segments arrive
* innermost-first (e.g. `['dashboard', 'cast']` for `cast.dashboard`).
* @param options The options object passed by `ha-form`.
* @returns The container path segments in configuration order.
*/
export const getFormContainerPath = (options?: { path?: string[] }): string[] =>
[...(options?.path ?? [])].reverse();
/**
* Strip array indices from a configuration path. Localization keys and
* documentation links mirror configuration paths without them (e.g.
* `config.cameras.title` covers every camera).
* @param path The configuration path segments.
* @returns The path segments without array indices.
*/
export const stripArrayIndices = (path: (string | number)[]): string[] =>
path.filter(
(segment): segment is string =>
typeof segment === 'string' && !/^\d+$/.test(segment),
);
+472
View File
@@ -0,0 +1,472 @@
import type {
HAFormExpandableSchema,
HAFormSchema,
HASelectSelectorOption,
} from '../../../ha/types';
import { localize } from '../../../localize/localize';
import { capabilityKeys } from '../../../types';
import { getImageFieldsSchema } from './common/image';
import { getProxySchema } from './common/proxy';
import { createNumberSelector, createSelectSelector } from './common/selectors';
const percentageSelector = createNumberSelector({ min: 0, max: 100 });
const getMediaLayoutSchema = (): HAFormExpandableSchema => ({
name: 'layout',
type: 'expandable',
title: localize('config.cameras.dimensions.layout.editor_label'),
icon: 'mdi:page-layout-body',
schema: [
{
name: 'fit',
selector: createSelectSelector([
{
value: 'contain',
label: localize('config.cameras.dimensions.layout.fits.contain'),
},
{
value: 'cover',
label: localize('config.cameras.dimensions.layout.fits.cover'),
},
{
value: 'fill',
label: localize('config.cameras.dimensions.layout.fits.fill'),
},
]),
},
{
name: 'position',
type: 'expandable',
title: localize('config.cameras.dimensions.layout.position.editor_label'),
schema: [
{ name: 'x', selector: percentageSelector },
{ name: 'y', selector: percentageSelector },
],
},
{
name: 'view_box',
type: 'expandable',
title: localize('config.cameras.dimensions.layout.view_box.editor_label'),
schema: [
{ name: 'top', selector: percentageSelector },
{ name: 'bottom', selector: percentageSelector },
{ name: 'left', selector: percentageSelector },
{ name: 'right', selector: percentageSelector },
],
},
{
name: 'pan',
type: 'expandable',
title: localize('config.cameras.dimensions.layout.pan.editor_label'),
schema: [
{ name: 'x', selector: percentageSelector },
{ name: 'y', selector: percentageSelector },
],
},
{
name: 'zoom',
selector: createNumberSelector({ min: 1, max: 10 }),
},
],
});
interface CameraSchemaOptions {
otherCameras: HASelectSelectorOption[];
folders: HASelectSelectorOption[];
}
/**
* Get the schema for a single camera (everything except the triggers group,
* which is rendered by a hand-built panel so it can host the events array
* list).
* @param options The camera and folder lists the dropdowns choose from.
* @returns The camera form schema.
*/
export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] => {
const capabilityOptions: HASelectSelectorOption[] = capabilityKeys.map((key) => ({
value: key,
label: localize(`config.cameras.capabilities.capabilities.${key}`),
}));
return [
{ name: 'camera_entity', selector: { entity: { domain: 'camera' } } },
{
name: 'live_provider',
selector: createSelectSelector([
{ value: 'auto', label: localize('config.cameras.live_providers.auto') },
{ value: 'ha', label: localize('config.cameras.live_providers.ha') },
{ value: 'image', label: localize('config.cameras.live_providers.image') },
{ value: 'jsmpeg', label: localize('config.cameras.live_providers.jsmpeg') },
{ value: 'go2rtc', label: localize('config.cameras.live_providers.go2rtc') },
{
value: 'go2rtc-experimental',
label: localize('config.cameras.live_providers.go2rtc-experimental'),
},
{
value: 'webrtc-card',
label: localize('config.cameras.live_providers.webrtc-card'),
},
]),
},
{ name: 'title', selector: { text: {} } },
{ name: 'icon', selector: { icon: {} } },
{ name: 'id', selector: { text: {} } },
{ name: 'always_error_if_entity_unavailable', selector: { boolean: {} } },
{
name: 'capabilities',
type: 'expandable',
title: localize('config.cameras.capabilities.editor_label'),
icon: 'mdi:cog-stop',
schema: [
{
name: 'disable',
selector: createSelectSelector(capabilityOptions, { multiple: true }),
},
{
name: 'disable_except',
selector: createSelectSelector(capabilityOptions, { multiple: true }),
},
{
name: 'force',
selector: createSelectSelector(
[
{
value: '2-way-audio',
label: localize('config.cameras.capabilities.capabilities.2-way-audio'),
},
],
{ multiple: true },
),
},
],
},
{
name: 'cast',
type: 'expandable',
title: localize('config.cameras.cast.editor_label'),
icon: 'mdi:cast',
schema: [
{
name: 'method',
selector: createSelectSelector([
{
value: 'standard',
label: localize('config.cameras.cast.methods.standard'),
},
{
value: 'dashboard',
label: localize('config.cameras.cast.methods.dashboard'),
},
]),
},
{
name: 'dashboard',
type: 'expandable',
title: localize('config.cameras.cast.dashboard.editor_label'),
schema: [
{ name: 'dashboard_path', selector: { text: {} } },
{ name: 'view_path', selector: { text: {} } },
],
},
],
},
{
name: 'dependencies',
type: 'expandable',
title: localize('config.cameras.dependencies.editor_label'),
icon: 'mdi:graph',
schema: [
{ name: 'all_cameras', selector: { boolean: {} } },
{
name: 'cameras',
selector: createSelectSelector(options.otherCameras, { multiple: true }),
},
],
},
{
name: 'dimensions',
type: 'expandable',
title: localize('config.cameras.dimensions.editor_label'),
icon: 'mdi:aspect-ratio',
schema: [
{ name: 'aspect_ratio', selector: { text: {} } },
{
name: 'rotation',
selector: createSelectSelector([
{ value: 0, label: localize('config.cameras.dimensions.rotations.0') },
{ value: 90, label: localize('config.cameras.dimensions.rotations.90') },
{ value: 180, label: localize('config.cameras.dimensions.rotations.180') },
{ value: 270, label: localize('config.cameras.dimensions.rotations.270') },
]),
},
getMediaLayoutSchema(),
],
},
{
// Nameless: a visual grouping only:
// `engine`/`frigate`/`motioneye`/`reolink` are direct camera keys, not
// nested under an `engine` object.
type: 'expandable',
title: localize('config.cameras.engines.editor_label'),
icon: 'mdi:engine',
docPath: 'cameras.engine',
schema: [
{
name: 'engine',
label: localize('config.cameras.engine'),
selector: createSelectSelector([
{ value: 'auto', label: localize('config.cameras.engines.auto') },
{ value: 'frigate', label: localize('config.cameras.engines.frigate') },
{ value: 'generic', label: localize('config.cameras.engines.generic') },
{ value: 'motioneye', label: localize('config.cameras.engines.motioneye') },
{ value: 'reolink', label: localize('config.cameras.engines.reolink') },
{ value: 'tplink', label: localize('config.cameras.engines.tplink') },
]),
},
{
name: 'frigate',
type: 'expandable',
title: localize('config.cameras.frigate.editor_label'),
icon: 'advanced-camera-card:frigate',
schema: [
{ name: 'camera_name', selector: { text: {} } },
{ name: 'url', selector: { text: {} } },
{ name: 'labels', selector: createSelectSelector([], { multiple: true }) },
{ name: 'zones', selector: createSelectSelector([], { multiple: true }) },
{ name: 'client_id', selector: { text: {} } },
],
},
{
name: 'motioneye',
type: 'expandable',
title: localize('config.cameras.motioneye.editor_label'),
icon: 'advanced-camera-card:motioneye',
schema: [
{ name: 'url', selector: { text: {} } },
{
name: 'images',
type: 'expandable',
title: localize('config.cameras.motioneye.images.editor_label'),
schema: [
{ name: 'directory_pattern', selector: { text: {} } },
{ name: 'file_pattern', selector: { text: {} } },
],
},
{
name: 'movies',
type: 'expandable',
title: localize('config.cameras.motioneye.movies.editor_label'),
schema: [
{ name: 'directory_pattern', selector: { text: {} } },
{ name: 'file_pattern', selector: { text: {} } },
],
},
],
},
{
name: 'reolink',
type: 'expandable',
title: localize('config.cameras.reolink.editor_label'),
icon: 'advanced-camera-card:reolink',
schema: [
{ name: 'url', selector: { text: {} } },
{
name: 'media_resolution',
label: localize('config.cameras.reolink.media_resolution.editor_label'),
selector: createSelectSelector([
{
value: 'high',
label: localize('config.cameras.reolink.media_resolution.high'),
},
{
value: 'low',
label: localize('config.cameras.reolink.media_resolution.low'),
},
]),
},
],
},
],
},
{
// Nameless: a visual grouping only: `go2rtc`/`image`/`webrtc_card` are
// direct camera keys.
type: 'expandable',
title: localize('config.cameras.live_provider_options.editor_label'),
icon: 'mdi:cctv',
docPath: 'cameras.live_provider',
schema: [
{
name: 'go2rtc',
type: 'expandable',
title: localize('config.cameras.go2rtc.editor_label'),
icon: 'mdi:eye',
schema: [
{
name: 'modes',
label: localize('config.cameras.go2rtc.modes.editor_label'),
selector: createSelectSelector(
[
{
value: 'webrtc',
label: localize('config.cameras.go2rtc.modes.webrtc'),
},
{ value: 'mse', label: localize('config.cameras.go2rtc.modes.mse') },
{ value: 'mp4', label: localize('config.cameras.go2rtc.modes.mp4') },
{
value: 'mjpeg',
label: localize('config.cameras.go2rtc.modes.mjpeg'),
},
],
{ multiple: true },
),
},
{ name: 'stream', selector: { text: {} } },
{ name: 'url', selector: { text: {} } },
{ name: 'metadata_fetch_timeout_seconds', selector: createNumberSelector() },
],
},
{
name: 'image',
type: 'expandable',
title: localize('config.cameras.image.editor_label'),
icon: 'mdi:image',
schema: getImageFieldsSchema(),
},
{
name: 'webrtc_card',
type: 'expandable',
title: localize('config.cameras.webrtc_card.editor_label'),
icon: 'mdi:webrtc',
schema: [
{ name: 'entity', selector: { entity: { domain: 'camera' } } },
{ name: 'url', selector: { text: {} } },
],
},
],
},
{
name: 'media',
type: 'expandable',
title: localize('config.cameras.media.editor_label'),
icon: 'mdi:play-box-outline',
schema: [
{
name: 'type',
selector: createSelectSelector([
{ value: 'auto', label: localize('config.common.media_types.auto') },
{ value: 'reviews', label: localize('config.common.media_types.reviews') },
{ value: 'events', label: localize('config.common.media_types.events') },
{
value: 'recordings',
label: localize('config.common.media_types.recordings'),
},
{ value: 'folder', label: localize('config.common.media_types.folder') },
]),
},
{
name: 'events_type',
selector: createSelectSelector([
{ value: 'all', label: localize('config.common.events_media_types.all') },
{
value: 'clips',
label: localize('config.common.events_media_types.clips'),
},
{
value: 'snapshots',
label: localize('config.common.events_media_types.snapshots'),
},
]),
},
{
name: 'reviewed',
selector: createSelectSelector([
{
value: 'unreviewed',
label: localize('config.cameras.media.revieweds.unreviewed'),
},
{
value: 'reviewed',
label: localize('config.cameras.media.revieweds.reviewed'),
},
{ value: 'all', label: localize('config.cameras.media.revieweds.all') },
]),
},
{
name: 'folders',
selector: createSelectSelector(options.folders, { multiple: true }),
},
],
},
getProxySchema({
title: localize('config.cameras.proxy.editor_label'),
includeLive: true,
includeMedia: true,
}),
];
};
/**
* Get the schema for a camera's trigger scalars (the events list is rendered
* separately by the array editor).
* @param entities The entity IDs offered for the trigger-entities field.
* @returns The triggers form schema.
*/
export const getCameraTriggersSchema = (): HAFormSchema[] => [
{ name: 'occupancy', selector: { boolean: {} } },
{ name: 'motion', selector: { boolean: {} } },
{ name: 'doorbell', selector: { boolean: {} } },
{ name: 'entities', selector: { entity: { multiple: true } } },
{
name: 'media_events',
label: localize('config.cameras.triggers.media_events.editor_label'),
selector: createSelectSelector(
[
{
value: 'events',
label: localize('config.cameras.triggers.media_events.events'),
},
{
value: 'clips',
label: localize('config.cameras.triggers.media_events.clips'),
},
{
value: 'snapshots',
label: localize('config.cameras.triggers.media_events.snapshots'),
},
],
{ multiple: true },
),
},
{
name: 'reviews',
type: 'expandable',
title: localize('config.cameras.triggers.reviews.editor_label'),
icon: 'mdi:check-circle',
schema: [
{
name: 'severities',
label: localize('common.severity'),
selector: createSelectSelector(
[
{ value: 'high', label: localize('common.severities.high') },
{ value: 'medium', label: localize('common.severities.medium') },
{ value: 'low', label: localize('common.severities.low') },
],
{ multiple: true },
),
},
{ name: 'description', selector: { boolean: {} } },
],
},
];
/**
* Get the schema for a single Home Assistant event trigger.
* @returns The event form schema.
*/
export const getTriggerEventSchema = (): HAFormSchema[] => [
{ name: 'event_type', selector: { text: {} } },
{ name: 'event_data', selector: { object: {} } },
];
@@ -0,0 +1,79 @@
import { BUTTON_SIZE_MIN } from '../../../../../config/schema/common/const';
import type {
HAFormExpandableSchema,
HASelectSelectorOption,
} from '../../../../../ha/types';
import { localize } from '../../../../../localize/localize';
import { createNumberSelector, createSelectSelector } from '../selectors';
/**
* Get the schema for the next/previous controls.
* @param options Which style and auto-hide values to offer (they vary by
* section: `thumbnails` and `icons` styles and the `call` auto-hide only apply
* where the section supports them).
* @returns The form schema.
*/
export const getNextPreviousSchema = (options?: {
allowIcons?: boolean;
allowThumbnails?: boolean;
allowCall?: boolean;
}): HAFormExpandableSchema => {
const styleOptions: HASelectSelectorOption[] = [
{
value: 'chevrons',
label: localize('config.common.controls.next_previous.styles.chevrons'),
},
];
if (options?.allowIcons) {
styleOptions.push({
value: 'icons',
label: localize('config.common.controls.next_previous.styles.icons'),
});
}
styleOptions.push({
value: 'none',
label: localize('config.common.controls.next_previous.styles.none'),
});
if (options?.allowThumbnails) {
styleOptions.push({
value: 'thumbnails',
label: localize('config.common.controls.next_previous.styles.thumbnails'),
});
}
const autoHideOptions: HASelectSelectorOption[] = [];
if (options?.allowCall) {
autoHideOptions.push({
value: 'call',
label: localize('config.common.auto_hide_conditions.call'),
});
}
autoHideOptions.push({
value: 'casting',
label: localize('config.common.auto_hide_conditions.casting'),
});
return {
name: 'next_previous',
type: 'expandable',
title: localize('config.common.controls.next_previous.editor_label'),
icon: 'mdi:arrow-right-bold-circle',
schema: [
{
name: 'style',
label: localize('config.common.controls.next_previous.style'),
selector: createSelectSelector(styleOptions),
},
{
name: 'size',
label: localize('config.common.controls.next_previous.size'),
selector: createNumberSelector({ min: BUTTON_SIZE_MIN }),
},
{
name: 'auto_hide',
label: localize('config.common.controls.next_previous.auto_hide'),
selector: createSelectSelector(autoHideOptions, { multiple: true }),
},
],
};
};
@@ -0,0 +1,112 @@
import type {
HAFormExpandableSchema,
HASelectSelectorOption,
} from '../../../../../ha/types';
import { localize } from '../../../../../localize/localize';
import { createSelectSelector } from '../selectors';
// The PTZ controls are shared between live (physical or digital pan/tilt/zoom)
// and the media viewer (digital pan/zoom of the media). The label keys live
// under `config.common.controls.ptz` for both. Live offers an `auto` mode
// (physical PTZ when the camera supports it); the viewer is digital-only.
export const getPTZSchema = (options?: {
includeAutoMode?: boolean;
}): HAFormExpandableSchema => {
const modeOptions: HASelectSelectorOption[] = [
{ value: 'off', label: localize('config.common.controls.ptz.modes.off') },
];
if (options?.includeAutoMode) {
modeOptions.push({
value: 'auto',
label: localize('config.common.controls.ptz.modes.auto'),
});
}
modeOptions.push({
value: 'on',
label: localize('config.common.controls.ptz.modes.on'),
});
return {
name: 'ptz',
type: 'expandable',
title: localize('config.common.controls.ptz.editor_label'),
icon: 'mdi:pan',
schema: [
{
name: 'mode',
label: localize('config.common.controls.ptz.mode'),
selector: createSelectSelector(modeOptions),
},
{
name: 'type',
label: localize('config.common.controls.ptz.type'),
selector: createSelectSelector([
{
value: 'buttons',
label: localize('config.common.controls.ptz.types.buttons'),
},
{
value: 'gestures',
label: localize('config.common.controls.ptz.types.gestures'),
},
]),
},
{
name: 'position',
label: localize('config.common.controls.ptz.position'),
selector: createSelectSelector([
{
value: 'top-left',
label: localize('config.common.controls.ptz.positions.top-left'),
},
{
value: 'top-right',
label: localize('config.common.controls.ptz.positions.top-right'),
},
{
value: 'bottom-left',
label: localize('config.common.controls.ptz.positions.bottom-left'),
},
{
value: 'bottom-right',
label: localize('config.common.controls.ptz.positions.bottom-right'),
},
]),
},
{
name: 'orientation',
label: localize('config.common.controls.ptz.orientation'),
selector: createSelectSelector([
{
value: 'vertical',
label: localize('config.common.controls.ptz.orientations.vertical'),
},
{
value: 'horizontal',
label: localize('config.common.controls.ptz.orientations.horizontal'),
},
]),
},
{
name: 'hide_pan_tilt',
label: localize('config.common.controls.ptz.hide_pan_tilt'),
selector: { boolean: {} },
},
{
name: 'hide_zoom',
label: localize('config.common.controls.ptz.hide_zoom'),
selector: { boolean: {} },
},
{
name: 'hide_home',
label: localize('config.common.controls.ptz.hide_home'),
selector: { boolean: {} },
},
{
name: 'hide_type',
label: localize('config.common.controls.ptz.hide_type'),
selector: { boolean: {} },
},
],
};
};
@@ -0,0 +1,93 @@
import {
THUMBNAIL_WIDTH_MAX,
THUMBNAIL_WIDTH_MIN,
} from '../../../../../config/schema/common/controls/thumbnails';
import type { HAFormExpandableSchema, HAFormSchema } from '../../../../../ha/types';
import { localize } from '../../../../../localize/localize';
import { createNumberSelector, createSelectSelector } from '../selectors';
/**
* Get the schema for a thumbnails control group.
* @param options Whether to include the mode field.
* @returns The form schema for the group.
*/
export const getThumbnailsSchema = (options?: {
includeMode?: boolean;
}): HAFormExpandableSchema => {
const schema: HAFormSchema[] = [];
if (options?.includeMode) {
schema.push({
name: 'mode',
label: localize('config.common.controls.thumbnails.mode'),
selector: createSelectSelector([
{
value: 'none',
label: localize('config.common.controls.thumbnails.modes.none'),
},
{
value: 'above',
label: localize('config.common.controls.thumbnails.modes.above'),
},
{
value: 'below',
label: localize('config.common.controls.thumbnails.modes.below'),
},
{
value: 'left',
label: localize('config.common.controls.thumbnails.modes.left'),
},
{
value: 'right',
label: localize('config.common.controls.thumbnails.modes.right'),
},
]),
});
}
schema.push(
{
name: 'size',
label: localize('config.common.controls.thumbnails.size'),
selector: createNumberSelector({
min: THUMBNAIL_WIDTH_MIN,
max: THUMBNAIL_WIDTH_MAX,
}),
},
{
name: 'show_details',
label: localize('config.common.controls.thumbnails.show_details'),
selector: { boolean: {} },
},
{
name: 'show_favorite_control',
label: localize('config.common.controls.thumbnails.show_favorite_control'),
selector: { boolean: {} },
},
{
name: 'show_timeline_control',
label: localize('config.common.controls.thumbnails.show_timeline_control'),
selector: { boolean: {} },
},
{
name: 'show_download_control',
label: localize('config.common.controls.thumbnails.show_download_control'),
selector: { boolean: {} },
},
{
name: 'show_review_control',
label: localize('config.common.controls.thumbnails.show_review_control'),
selector: { boolean: {} },
},
{
name: 'show_info_control',
label: localize('config.common.controls.thumbnails.show_info_control'),
selector: { boolean: {} },
},
);
return {
name: 'thumbnails',
type: 'expandable',
title: localize('config.common.controls.thumbnails.editor_label'),
icon: 'mdi:image-text',
schema,
};
};
@@ -0,0 +1,111 @@
import type { HAFormExpandableSchema, HAFormSchema } from '../../../../../ha/types';
import { localize } from '../../../../../localize/localize';
import { createNumberSelector, createSelectSelector } from '../selectors';
/**
* Get the schema for the core timeline fields (shared between the timeline
* section and the mini timelines).
* @param options Whether to include `pan_mode`. Its non-`pan` modes seek
* within playing media, so only a mini timeline shown alongside media has it.
* @returns The form schema.
*/
export const getTimelineCoreSchema = (options?: {
includePanMode?: boolean;
}): HAFormSchema[] => {
const schema: HAFormSchema[] = [
{
name: 'style',
label: localize('config.common.timeline.style'),
selector: createSelectSelector([
{ value: 'ribbon', label: localize('config.common.timeline.styles.ribbon') },
{ value: 'stack', label: localize('config.common.timeline.styles.stack') },
]),
},
];
if (options?.includePanMode) {
schema.push({
name: 'pan_mode',
label: localize('config.common.controls.timeline.pan_mode'),
selector: createSelectSelector([
{
value: 'pan',
label: localize('config.common.controls.timeline.pan_modes.pan'),
},
{
value: 'seek',
label: localize('config.common.controls.timeline.pan_modes.seek'),
},
{
value: 'seek-in-media',
label: localize('config.common.controls.timeline.pan_modes.seek-in-media'),
},
{
value: 'seek-in-camera',
label: localize('config.common.controls.timeline.pan_modes.seek-in-camera'),
},
]),
});
}
schema.push(
{
name: 'window_seconds',
label: localize('config.common.timeline.window_seconds'),
selector: createNumberSelector(),
},
{
name: 'clustering_threshold',
label: localize('config.common.timeline.clustering_threshold'),
selector: createNumberSelector(),
},
{
name: 'show_recordings',
label: localize('config.common.timeline.show_recordings'),
selector: { boolean: {} },
},
{
name: 'format',
type: 'expandable',
title: localize('config.common.controls.timeline.format.editor_label'),
icon: 'mdi:clock-edit',
schema: [
{
name: '24h',
label: localize('config.common.controls.timeline.format.24h'),
selector: { boolean: {} },
},
],
},
);
return schema;
};
/**
* Get the schema for a mini timeline (the timeline shown for
* live/media_viewer): the placement `mode` plus the shared core fields
* (including `pan_mode`, which only mini timelines honor).
* @returns The expandable form schema.
*/
export const getMiniTimelineSchema = (): HAFormExpandableSchema => ({
name: 'timeline',
type: 'expandable',
title: localize('config.common.controls.timeline.editor_label'),
icon: 'mdi:chart-gantt',
schema: [
{
name: 'mode',
label: localize('config.common.controls.timeline.mode'),
selector: createSelectSelector([
{ value: 'none', label: localize('config.common.controls.timeline.modes.none') },
{
value: 'above',
label: localize('config.common.controls.timeline.modes.above'),
},
{
value: 'below',
label: localize('config.common.controls.timeline.modes.below'),
},
]),
},
...getTimelineCoreSchema({ includePanMode: true }),
],
});
@@ -0,0 +1,57 @@
import type { HAFormExpandableSchema } from '../../../../ha/types';
import { localize } from '../../../../localize/localize';
import { createNumberSelector, createSelectSelector } from './selectors';
/**
* Get the schema for the view display controls (single vs grid layout).
* @returns The form schema.
*/
export const getDisplaySchema = (): HAFormExpandableSchema => ({
name: 'display',
type: 'expandable',
title: localize('config.common.display.editor_label'),
icon: 'mdi:palette-swatch',
schema: [
{
name: 'mode',
label: localize('config.common.display.mode'),
selector: createSelectSelector([
{ value: 'single', label: localize('display_modes.single') },
{ value: 'grid', label: localize('display_modes.grid') },
]),
},
{
name: 'grid_selected_position',
label: localize('config.common.display.grid_selected_position'),
selector: createSelectSelector([
{
value: 'default',
label: localize('config.common.display.grid_selected_positions.default'),
},
{
value: 'first',
label: localize('config.common.display.grid_selected_positions.first'),
},
{
value: 'last',
label: localize('config.common.display.grid_selected_positions.last'),
},
]),
},
{
name: 'grid_selected_width_factor',
label: localize('config.common.display.grid_selected_width_factor'),
selector: createNumberSelector({ min: 0 }),
},
{
name: 'grid_columns',
label: localize('config.common.display.grid_columns'),
selector: createNumberSelector({ min: 0 }),
},
{
name: 'grid_max_columns',
label: localize('config.common.display.grid_max_columns'),
selector: createNumberSelector({ min: 0 }),
},
],
});
@@ -0,0 +1,45 @@
import type { HAFormSchema } from '../../../../ha/types';
import { localize } from '../../../../localize/localize';
import { createNumberSelector, createSelectSelector } from './selectors';
/**
* Get the schema for a set of image source fields.
* @returns The form schema.
*/
export const getImageFieldsSchema = (): HAFormSchema[] => [
{
name: 'mode',
label: localize('config.common.image.mode'),
selector: createSelectSelector([
{ value: 'auto', label: localize('config.common.image.modes.auto') },
{ value: 'camera', label: localize('config.common.image.modes.camera') },
{ value: 'default', label: localize('config.common.image.modes.default') },
{ value: 'entity', label: localize('config.common.image.modes.entity') },
{
value: 'screensaver',
label: localize('config.common.image.modes.screensaver'),
},
{ value: 'url', label: localize('config.common.image.modes.url') },
]),
},
{
name: 'url',
label: localize('config.common.image.url'),
selector: { text: {} },
},
{
name: 'entity',
label: localize('config.common.image.entity'),
selector: { entity: {} },
},
{
name: 'entity_parameters',
label: localize('config.common.image.entity_parameters'),
selector: { text: {} },
},
{
name: 'refresh_seconds',
label: localize('config.common.image.refresh_seconds'),
selector: createNumberSelector(),
},
];
@@ -0,0 +1,12 @@
import type { HASelectSelectorOption } from '../../../../ha/types';
import { localize } from '../../../../localize/localize';
/**
* Get the options for an interaction mode select.
* @returns The select options.
*/
export const getInteractionModeOptions = (): HASelectSelectorOption[] => [
{ value: 'all', label: localize('config.common.interaction_modes.all') },
{ value: 'inactive', label: localize('config.common.interaction_modes.inactive') },
{ value: 'active', label: localize('config.common.interaction_modes.active') },
];
@@ -0,0 +1,42 @@
import type { HASelectSelectorOption } from '../../../../ha/types';
import { localize } from '../../../../localize/localize';
// A media-action condition option. `value` is the raw config enum value. The
// label key defaults to the value; `microphone`/`call` override it because
// their wording depends on mute vs unmute.
const condition = (value: string, labelKey: string = value): HASelectSelectorOption => ({
value,
label: localize(`config.common.media_action_conditions.${labelKey}`),
});
export const getMediaActionPositiveOptions = (): HASelectSelectorOption[] => [
condition('selected'),
condition('visible'),
];
export const getMediaActionNegativeOptions = (): HASelectSelectorOption[] => [
condition('unselected'),
condition('hidden'),
];
export const getLiveAutoMuteOptions = (): HASelectSelectorOption[] => [
...getMediaActionNegativeOptions(),
condition('microphone', 'microphone_mute'),
condition('call', 'call_mute'),
];
export const getLiveAutoUnmuteOptions = (): HASelectSelectorOption[] => [
...getMediaActionPositiveOptions(),
condition('microphone', 'microphone_unmute'),
condition('call', 'call_unmute'),
];
export const getMicrophoneMuteOptions = (): HASelectSelectorOption[] => [
...getMediaActionNegativeOptions(),
condition('call', 'call_mute'),
];
export const getMicrophoneUnmuteOptions = (): HASelectSelectorOption[] => [
...getMediaActionPositiveOptions(),
condition('call', 'call_unmute'),
];
@@ -0,0 +1,104 @@
import type {
HAFormExpandableSchema,
HAFormSchema,
HASelectSelectorOption,
} from '../../../../ha/types';
import { localize } from '../../../../localize/localize';
import { createSelectSelector } from './selectors';
/**
* Get the schema for a media proxy group.
* @param options The group title and which optional fields to include.
* @returns The form schema for the group.
*/
export const getProxySchema = (options: {
title: string;
includeEnabled?: boolean;
includeLive?: boolean;
includeMedia?: boolean;
}): HAFormExpandableSchema => {
const proxyModeOptions: HASelectSelectorOption[] = [
{ value: 'auto', label: localize('config.common.proxy.modes.auto') },
{ value: true, label: localize('config.common.proxy.modes.true') },
{ value: false, label: localize('config.common.proxy.modes.false') },
];
const schema: HAFormSchema[] = [];
if (options.includeEnabled) {
schema.push({
name: 'enabled',
label: localize('config.common.proxy.modes.true'),
selector: { boolean: {} },
});
}
if (options.includeLive) {
schema.push({
name: 'live',
label: localize('config.cameras.proxy.live'),
selector: createSelectSelector(proxyModeOptions),
});
}
if (options.includeMedia) {
schema.push({
name: 'media',
label: localize('config.cameras.proxy.media'),
selector: createSelectSelector(proxyModeOptions),
});
}
schema.push(
{
name: 'dynamic',
label: localize('config.common.proxy.dynamic'),
selector: { boolean: {} },
},
{
name: 'ssl_verification',
label: localize('config.common.proxy.ssl_verification.editor_label'),
selector: createSelectSelector([
{
value: 'auto',
label: localize('config.common.proxy.ssl_verification.auto'),
},
{
value: true,
label: localize('config.common.proxy.ssl_verification.true'),
},
{
value: false,
label: localize('config.common.proxy.ssl_verification.false'),
},
]),
},
{
name: 'ssl_ciphers',
label: localize('config.common.proxy.ssl_ciphers.editor_label'),
selector: createSelectSelector([
{ value: 'auto', label: localize('config.common.proxy.ssl_ciphers.auto') },
{
value: 'default',
label: localize('config.common.proxy.ssl_ciphers.default'),
},
{
value: 'insecure',
label: localize('config.common.proxy.ssl_ciphers.insecure'),
},
{
value: 'intermediate',
label: localize('config.common.proxy.ssl_ciphers.intermediate'),
},
{
value: 'modern',
label: localize('config.common.proxy.ssl_ciphers.modern'),
},
]),
},
);
return {
name: 'proxy',
type: 'expandable',
title: options.title,
icon: 'mdi:arrow-decision',
schema,
};
};
@@ -0,0 +1,43 @@
import type {
HANumberSelector,
HASelectSelector,
HASelectSelectorOption,
} from '../../../../ha/types';
/**
* Create a dropdown select selector. Free-form values are allowed only when
* no options are given.
* @param options The options to offer.
* @param params Whether multiple values may be selected.
* @returns A select selector.
*/
export const createSelectSelector = (
options: string[] | HASelectSelectorOption[],
params?: { multiple?: boolean },
): HASelectSelector => ({
select: {
mode: 'dropdown',
multiple: !!params?.multiple,
custom_value: !options.length,
options,
},
});
/**
* Create a number selector: a slider when bounded by a maximum, otherwise an
* input box.
* @param params The bounds and step size.
* @returns A number selector.
*/
export const createNumberSelector = (params?: {
min?: number;
max?: number;
step?: number;
}): HANumberSelector => ({
number: {
min: params?.min ?? 0,
max: params?.max,
mode: params?.max === undefined ? 'box' : 'slider',
step: params?.step,
},
});
@@ -0,0 +1,14 @@
import type { HAFormSchema } from '../../../../ha/types';
import { localize } from '../../../../localize/localize';
import { createSelectSelector } from './selectors';
// The "slide" transition effect between media. The field label auto-derives
// from each section's path; only the option labels are shared (under
// `config.common.transition_effects`).
export const getTransitionEffectSchema = (): HAFormSchema => ({
name: 'transition_effect',
selector: createSelectSelector([
{ value: 'none', label: localize('config.common.transition_effects.none') },
{ value: 'slide', label: localize('config.common.transition_effects.slide') },
]),
});
@@ -0,0 +1,40 @@
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createSelectSelector } from './common/selectors';
/**
* Get the form for the dimensions section.
* @returns The section forms.
*/
export const getDimensionsSectionForms = (): EditorForm[] => [
{
basePath: ['dimensions'],
schema: [
{
name: 'aspect_ratio_mode',
selector: createSelectSelector([
{
value: 'dynamic',
label: localize('config.dimensions.aspect_ratio_modes.dynamic'),
},
{
value: 'static',
label: localize('config.dimensions.aspect_ratio_modes.static'),
},
{
value: 'unconstrained',
label: localize('config.dimensions.aspect_ratio_modes.unconstrained'),
},
]),
},
{
name: 'aspect_ratio',
selector: { text: {} },
},
{
name: 'height',
selector: { text: {} },
},
],
},
];
@@ -0,0 +1,41 @@
import type { HAFormSchema } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import { createSelectSelector } from './common/selectors';
/**
* Get the schema for a single folder. Array sections expose just the item
* fields; the editor renders one form per folder at `basePath: ['folders', i]`.
* @returns The form schema for one folder.
*/
export const getFolderSchema = (): HAFormSchema[] => [
{
name: 'type',
selector: createSelectSelector([
{ value: 'ha', label: localize('config.folders.types.ha') },
]),
},
{
name: 'title',
selector: { text: {} },
},
{
name: 'icon',
selector: { icon: {} },
},
{
name: 'id',
selector: { text: {} },
},
{
name: 'ha',
type: 'expandable',
title: localize('config.folders.ha.editor_label'),
icon: 'mdi:home-assistant',
schema: [
{
name: 'url',
selector: { text: {} },
},
],
},
];
+25
View File
@@ -0,0 +1,25 @@
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { getImageFieldsSchema } from './common/image';
import { getProxySchema } from './common/proxy';
/**
* Get the section forms for the image section.
* @returns The section forms.
*/
export const getImageSectionForms = (): EditorForm[] => [
{
basePath: ['image'],
schema: [
...getImageFieldsSchema(),
{
name: 'zoomable',
selector: { boolean: {} },
},
getProxySchema({
title: localize('config.common.image.proxy.editor_label'),
includeEnabled: true,
}),
],
},
];
+164
View File
@@ -0,0 +1,164 @@
import { BUTTON_SIZE_MIN } from '../../../config/schema/common/const';
import type { HAFormExpandableSchema } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { getNextPreviousSchema } from './common/controls/next-previous';
import { getPTZSchema } from './common/controls/ptz';
import { getThumbnailsSchema } from './common/controls/thumbnails';
import { getMiniTimelineSchema } from './common/controls/timeline';
import { getDisplaySchema } from './common/display';
import {
getLiveAutoMuteOptions,
getLiveAutoUnmuteOptions,
getMediaActionNegativeOptions,
getMediaActionPositiveOptions,
getMicrophoneMuteOptions,
getMicrophoneUnmuteOptions,
} from './common/media-actions';
import { createNumberSelector, createSelectSelector } from './common/selectors';
import { getTransitionEffectSchema } from './common/transition-effect';
const getCallSchema = (): HAFormExpandableSchema => ({
name: 'call',
type: 'expandable',
title: localize('config.live.controls.call.editor_label'),
icon: 'mdi:phone',
schema: [
{ name: 'lock', selector: { boolean: {} } },
{
name: 'ringtone',
type: 'expandable',
title: localize('config.live.controls.call.ringtone.editor_label'),
icon: 'mdi:music-note',
schema: [
{
name: 'type',
selector: createSelectSelector([
{
value: 'none',
label: localize('config.live.controls.call.ringtone.types.none'),
},
{
value: 'chime',
label: localize('config.live.controls.call.ringtone.types.chime'),
},
{
value: 'westminster',
label: localize('config.live.controls.call.ringtone.types.westminster'),
},
{
value: 'arpeggio',
label: localize('config.live.controls.call.ringtone.types.arpeggio'),
},
{
value: 'melody',
label: localize('config.live.controls.call.ringtone.types.melody'),
},
{
value: 'custom',
label: localize('config.live.controls.call.ringtone.types.custom'),
},
]),
},
{ name: 'url', selector: { text: {} } },
{ name: 'repeat', selector: createNumberSelector({ min: 0 }) },
],
},
{ name: 'unanswered_timeout_seconds', selector: createNumberSelector({ min: 0 }) },
{ name: 'button_size', selector: createNumberSelector({ min: BUTTON_SIZE_MIN }) },
],
});
const getControlsSchema = (): HAFormExpandableSchema => ({
name: 'controls',
type: 'expandable',
title: localize('config.live.controls.editor_label'),
icon: 'mdi:gamepad',
schema: [
{
name: 'builtin',
label: localize('config.common.controls.builtin'),
selector: { boolean: {} },
},
{
name: 'wheel',
label: localize('config.common.controls.wheel'),
selector: { boolean: {} },
},
getCallSchema(),
getNextPreviousSchema({ allowIcons: true, allowCall: true }),
getThumbnailsSchema({ includeMode: true }),
getMiniTimelineSchema(),
getPTZSchema({ includeAutoMode: true }),
],
});
const getMicrophoneSchema = (): HAFormExpandableSchema => ({
name: 'microphone',
type: 'expandable',
title: localize('config.live.microphone.editor_label'),
icon: 'mdi:microphone',
schema: [
{ name: 'disconnect_seconds', selector: createNumberSelector({ min: 0 }) },
{ name: 'always_connected', selector: { boolean: {} } },
{
name: 'auto_mute',
selector: createSelectSelector(getMicrophoneMuteOptions(), { multiple: true }),
},
{
name: 'auto_unmute',
selector: createSelectSelector(getMicrophoneUnmuteOptions(), { multiple: true }),
},
{
name: 'mute_after_microphone_mute_seconds',
selector: createNumberSelector({ min: 0 }),
},
],
});
/**
* Get the forms for the live section.
* @returns The section forms.
*/
export const getLiveSectionForms = (): EditorForm[] => [
{
basePath: ['live'],
schema: [
{ name: 'preload', selector: { boolean: {} } },
{ name: 'draggable', selector: { boolean: {} } },
{ name: 'zoomable', selector: { boolean: {} } },
{ name: 'lazy_load', selector: { boolean: {} } },
{
name: 'lazy_unload',
selector: createSelectSelector(getMediaActionNegativeOptions(), {
multiple: true,
}),
},
{
name: 'auto_play',
selector: createSelectSelector(getMediaActionPositiveOptions(), {
multiple: true,
}),
},
{
name: 'auto_pause',
selector: createSelectSelector(getMediaActionNegativeOptions(), {
multiple: true,
}),
},
{
name: 'auto_mute',
selector: createSelectSelector(getLiveAutoMuteOptions(), { multiple: true }),
},
{
name: 'auto_unmute',
selector: createSelectSelector(getLiveAutoUnmuteOptions(), { multiple: true }),
},
getTransitionEffectSchema(),
{ name: 'show_image_during_load', selector: { boolean: {} } },
getDisplaySchema(),
getControlsSchema(),
getMicrophoneSchema(),
],
},
];
@@ -0,0 +1,34 @@
import type { HAFormExpandableSchema } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { getThumbnailsSchema } from './common/controls/thumbnails';
import { createSelectSelector } from './common/selectors';
const getFilterSchema = (): HAFormExpandableSchema => ({
name: 'filter',
type: 'expandable',
title: localize('config.common.controls.filter.editor_label'),
icon: 'mdi:filter-cog',
schema: [
{
name: 'mode',
label: localize('config.common.controls.filter.mode'),
selector: createSelectSelector([
{ value: 'none', label: localize('config.common.controls.filter.modes.none') },
{ value: 'left', label: localize('config.common.controls.filter.modes.left') },
{ value: 'right', label: localize('config.common.controls.filter.modes.right') },
]),
},
],
});
/**
* Get the form for the media gallery section.
* @returns The section forms.
*/
export const getMediaGallerySectionForms = (): EditorForm[] => [
{
basePath: ['media_gallery', 'controls'],
schema: [getThumbnailsSchema(), getFilterSchema()],
},
];
@@ -0,0 +1,80 @@
import type { HAFormExpandableSchema } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { getNextPreviousSchema } from './common/controls/next-previous';
import { getPTZSchema } from './common/controls/ptz';
import { getThumbnailsSchema } from './common/controls/thumbnails';
import { getMiniTimelineSchema } from './common/controls/timeline';
import { getDisplaySchema } from './common/display';
import {
getMediaActionNegativeOptions,
getMediaActionPositiveOptions,
} from './common/media-actions';
import { createSelectSelector } from './common/selectors';
import { getTransitionEffectSchema } from './common/transition-effect';
const getControlsSchema = (): HAFormExpandableSchema => ({
name: 'controls',
type: 'expandable',
title: localize('config.media_viewer.controls.editor_label'),
icon: 'mdi:gamepad',
schema: [
{
name: 'builtin',
label: localize('config.common.controls.builtin'),
selector: { boolean: {} },
},
{
name: 'wheel',
label: localize('config.common.controls.wheel'),
selector: { boolean: {} },
},
getNextPreviousSchema({ allowThumbnails: true }),
getThumbnailsSchema({ includeMode: true }),
getMiniTimelineSchema(),
getPTZSchema({ includeAutoMode: false }),
],
});
/**
* Get the forms for the media viewer section.
* @returns The section forms.
*/
export const getMediaViewerSectionForms = (): EditorForm[] => [
{
basePath: ['media_viewer'],
schema: [
{
name: 'auto_play',
selector: createSelectSelector(getMediaActionPositiveOptions(), {
multiple: true,
}),
},
{
name: 'auto_pause',
selector: createSelectSelector(getMediaActionNegativeOptions(), {
multiple: true,
}),
},
{
name: 'auto_mute',
selector: createSelectSelector(getMediaActionNegativeOptions(), {
multiple: true,
}),
},
{
name: 'auto_unmute',
selector: createSelectSelector(getMediaActionPositiveOptions(), {
multiple: true,
}),
},
{ name: 'draggable', selector: { boolean: {} } },
{ name: 'zoomable', selector: { boolean: {} } },
{ name: 'lazy_load', selector: { boolean: {} } },
getTransitionEffectSchema(),
{ name: 'snapshot_click_plays_clip', selector: { boolean: {} } },
getDisplaySchema(),
getControlsSchema(),
],
},
];
+176
View File
@@ -0,0 +1,176 @@
import { BUTTON_SIZE_MIN, MENU_PRIORITY_MAX } from '../../../config/schema/common/const';
import type { HAFormExpandableSchema, HAFormSchema } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createNumberSelector, createSelectSelector } from './common/selectors';
// The menu buttons in their display order.
const MENU_BUTTONS = [
// Iris menu button is first.
'iris',
// Alphabetical.
'call',
'camera_ui',
'cameras',
'clips',
'display_mode',
'download',
'expand',
'folders',
'fullscreen',
'gallery',
'image',
'info',
'live',
'media_player',
'microphone',
'mute',
'pip',
'play',
'ptz_controls',
'ptz_home',
'recordings',
'reviews',
'screenshot',
'set_review',
'snapshots',
'substreams',
'timeline',
];
// The microphone button alone carries a `type` (momentary vs toggle).
const getMicrophoneTypeSchema = (): HAFormSchema => ({
name: 'type',
label: localize('config.menu.buttons.type'),
selector: createSelectSelector([
{ value: 'momentary', label: localize('config.menu.buttons.types.momentary') },
{ value: 'toggle', label: localize('config.menu.buttons.types.toggle') },
]),
});
const getMenuButtonSchema = (
button: string,
extraSchema: HAFormSchema[] = [],
): HAFormExpandableSchema => ({
name: button,
type: 'expandable',
title: localize(`config.menu.buttons.${button}`),
icon: 'mdi:gesture-tap-button',
schema: [
{
name: 'enabled',
label: localize('config.menu.buttons.enabled'),
selector: { boolean: {} },
},
{
name: 'alignment',
label: localize('config.menu.buttons.alignment'),
selector: createSelectSelector([
{
value: 'matching',
label: localize('config.menu.buttons.alignments.matching'),
},
{
value: 'opposing',
label: localize('config.menu.buttons.alignments.opposing'),
},
]),
},
{
name: 'permanent',
label: localize('config.menu.buttons.permanent'),
selector: { boolean: {} },
},
{
name: 'priority',
label: localize('config.menu.buttons.priority'),
selector: createNumberSelector({ max: MENU_PRIORITY_MAX }),
},
{
name: 'icon',
label: localize('config.menu.buttons.icon'),
selector: { icon: {} },
},
{
name: 'state_color',
label: localize('config.menu.buttons.state_color'),
selector: { boolean: {} },
},
{
name: 'inert',
label: localize('config.menu.buttons.inert'),
selector: { boolean: {} },
},
...extraSchema,
],
});
/**
* Get the forms for the menu section.
* @returns The section forms.
*/
export const getMenuSectionForms = (): EditorForm[] => [
{
basePath: ['menu'],
schema: [
{
name: 'style',
selector: createSelectSelector([
{ value: 'none', label: localize('config.menu.styles.none') },
{ value: 'hidden', label: localize('config.menu.styles.hidden') },
{ value: 'overlay', label: localize('config.menu.styles.overlay') },
{ value: 'hover', label: localize('config.menu.styles.hover') },
{ value: 'hover-card', label: localize('config.menu.styles.hover-card') },
{ value: 'outside', label: localize('config.menu.styles.outside') },
]),
},
{
name: 'position',
selector: createSelectSelector([
{ value: 'left', label: localize('config.menu.positions.left') },
{ value: 'right', label: localize('config.menu.positions.right') },
{ value: 'top', label: localize('config.menu.positions.top') },
{ value: 'bottom', label: localize('config.menu.positions.bottom') },
]),
},
{
name: 'alignment',
selector: createSelectSelector([
{ value: 'left', label: localize('config.menu.alignments.left') },
{ value: 'right', label: localize('config.menu.alignments.right') },
{ value: 'top', label: localize('config.menu.alignments.top') },
{ value: 'bottom', label: localize('config.menu.alignments.bottom') },
]),
},
{
name: 'auto_hide',
selector: createSelectSelector(
[
{
value: 'call',
label: localize('config.common.auto_hide_conditions.call'),
},
{
value: 'casting',
label: localize('config.common.auto_hide_conditions.casting'),
},
],
{ multiple: true },
),
},
{
name: 'button_size',
selector: createNumberSelector({ min: BUTTON_SIZE_MIN }),
},
],
},
{
basePath: ['menu', 'buttons'],
schema: MENU_BUTTONS.map((button) =>
button === 'microphone'
? getMenuButtonSchema(button, [getMicrophoneTypeSchema()])
: getMenuButtonSchema(button),
),
},
];
@@ -0,0 +1,46 @@
import { MEDIA_CHUNK_SIZE_MAX } from '../../../config/schema/performance';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createNumberSelector } from './common/selectors';
/**
* Get the form for the performance section (except the low-performance profile
* warning, which is a custom info row).
* @returns The section forms.
*/
export const getPerformanceSectionForms = (): EditorForm[] => [
{
basePath: ['performance'],
schema: [
{
name: 'features',
type: 'expandable',
title: localize('config.performance.features.editor_label'),
icon: 'mdi:feature-search',
schema: [
{ name: 'card_loading_indicator', selector: { boolean: {} } },
{ name: 'card_loading_effects', selector: { boolean: {} } },
{ name: 'animated_progress_indicator', selector: { boolean: {} } },
{
name: 'media_chunk_size',
selector: createNumberSelector({ max: MEDIA_CHUNK_SIZE_MAX }),
},
{
name: 'max_simultaneous_engine_requests',
selector: createNumberSelector({ min: 1 }),
},
],
},
{
name: 'style',
type: 'expandable',
title: localize('config.performance.style.editor_label'),
icon: 'mdi:palette-swatch-variant',
schema: [
{ name: 'border_radius', selector: { boolean: {} } },
{ name: 'box_shadow', selector: { boolean: {} } },
],
},
],
},
];
@@ -0,0 +1,31 @@
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createSelectSelector } from './common/selectors';
/**
* Get the form for the profiles section.
* @returns The section forms.
*/
export const getProfilesSectionForms = (): EditorForm[] => [
{
basePath: [],
schema: [
{
name: 'profiles',
label: localize('config.profiles.editor_label'),
selector: createSelectSelector(
[
{ value: 'casting', label: localize('config.profiles.casting') },
{ value: 'doorbell', label: localize('config.profiles.doorbell') },
{
value: 'low-performance',
label: localize('config.profiles.low-performance'),
},
{ value: 'scrubbing', label: localize('config.profiles.scrubbing') },
],
{ multiple: true },
),
},
],
},
];
@@ -0,0 +1,124 @@
import { CONF_CAMERAS, CONF_FOLDERS } from '../../../config/const';
import type { HASelectSelectorOption } from '../../../ha/types';
import type { EditorForm } from '../types';
import {
getCameraSchema,
getCameraTriggersSchema,
getTriggerEventSchema,
} from './cameras';
import { getDimensionsSectionForms } from './dimensions';
import { getFolderSchema } from './folders';
import { getImageSectionForms } from './image';
import { getLiveSectionForms } from './live';
import { getMediaGallerySectionForms } from './media-gallery';
import { getMediaViewerSectionForms } from './media-viewer';
import { getMenuSectionForms } from './menu';
import { getPerformanceSectionForms } from './performance';
import { getProfilesSectionForms } from './profiles';
import { getRemoteControlSectionForms } from './remote-control';
import { getStatusBarSectionForms } from './status-bar';
import { getTimelineSectionForms } from './timeline';
import { getViewKeyboardShortcutsSectionForms, getViewSectionForms } from './view';
// The forms an editor component asks for. A request carries its indices as
// numbers rather than being named by a path string, so no caller has to build
// a string key and no builder has to pull one apart again.
export type FormRequest =
| { kind: 'section'; name: string }
| { kind: 'folder'; index: number }
| { kind: 'camera'; index: number }
| { kind: 'camera-triggers'; cameraIndex: number }
| { kind: 'camera-event'; cameraIndex: number; eventIndex: number };
// The lists a form's dropdowns choose from, gathered from the rest of the
// configuration: the cameras and the folders. Entity fields are not among them:
// they use Home Assistant's own entity picker, which reads the entities itself.
// The cameras are in configuration order, so that a camera's own form can leave
// itself out.
export interface FormRequestOptions {
cameras: HASelectSelectorOption[];
folders: HASelectSelectorOption[];
}
const getSectionFormsByName = (name: string): EditorForm[] => {
switch (name) {
case 'dimensions':
return getDimensionsSectionForms();
case 'image':
return getImageSectionForms();
case 'live':
return getLiveSectionForms();
case 'media_gallery':
return getMediaGallerySectionForms();
case 'media_viewer':
return getMediaViewerSectionForms();
case 'menu':
return getMenuSectionForms();
case 'performance':
return getPerformanceSectionForms();
case 'profiles':
return getProfilesSectionForms();
case 'remote_control':
return getRemoteControlSectionForms();
case 'status_bar':
return getStatusBarSectionForms();
case 'timeline':
return getTimelineSectionForms();
case 'view':
return getViewSectionForms();
case 'view.keyboard_shortcuts':
return getViewKeyboardShortcutsSectionForms();
}
return [];
};
/**
* Get the forms for a request.
* @param request What forms are wanted.
* @param options The lists the form's dropdowns choose from.
* @returns The forms, or an empty list for a section that has none.
*/
export const getForms = (
request: FormRequest,
options: FormRequestOptions,
): EditorForm[] => {
switch (request.kind) {
case 'section':
return getSectionFormsByName(request.name);
case 'folder':
return [{ basePath: [CONF_FOLDERS, request.index], schema: getFolderSchema() }];
case 'camera':
return [
{
basePath: [CONF_CAMERAS, request.index],
schema: getCameraSchema({
// A camera cannot depend on itself.
otherCameras: options.cameras.filter(
(_camera, index) => index !== request.index,
),
folders: options.folders,
}),
},
];
case 'camera-triggers':
return [
{
basePath: [CONF_CAMERAS, request.cameraIndex, 'triggers'],
schema: getCameraTriggersSchema(),
},
];
case 'camera-event':
return [
{
basePath: [
CONF_CAMERAS,
request.cameraIndex,
'triggers',
'events',
request.eventIndex,
],
schema: getTriggerEventSchema(),
},
];
}
};
@@ -0,0 +1,42 @@
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createSelectSelector } from './common/selectors';
/**
* Get the form for the remote control section.
* @returns The section forms.
*/
export const getRemoteControlSectionForms = (): EditorForm[] => [
{
basePath: ['remote_control'],
schema: [
{
name: 'entities',
type: 'expandable',
title: localize('config.remote_control.entities.editor_label'),
icon: 'mdi:devices',
schema: [
{
name: 'camera',
selector: { entity: { domain: 'input_select' } },
},
{
name: 'camera_priority',
selector: createSelectSelector([
{
value: 'card',
label: localize('config.remote_control.entities.camera_priorities.card'),
},
{
value: 'entity',
label: localize(
'config.remote_control.entities.camera_priorities.entity',
),
},
]),
},
],
},
],
},
];
@@ -0,0 +1,100 @@
import { STATUS_BAR_PRIORITY_MAX } from '../../../config/schema/common/const';
import { STATUS_BAR_HEIGHT_MIN } from '../../../config/schema/status-bar';
import type { HAFormExpandableSchema } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createNumberSelector, createSelectSelector } from './common/selectors';
const STATUS_BAR_ITEMS = [
'engine',
'issues',
'resolution',
'severity',
'technology',
'title',
];
const getStatusBarItemSchema = (item: string): HAFormExpandableSchema => ({
name: item,
type: 'expandable',
title: localize(`config.status_bar.items.${item}`),
icon: 'mdi:feature-search',
schema: [
{
name: 'enabled',
label: localize('config.status_bar.items.enabled'),
selector: { boolean: {} },
},
{
name: 'permanent',
label: localize('config.status_bar.items.permanent'),
selector: { boolean: {} },
},
{
name: 'priority',
label: localize('config.status_bar.items.priority'),
selector: createNumberSelector({ max: STATUS_BAR_PRIORITY_MAX }),
},
],
});
/**
* Get the forms for the status bar section.
* @returns The section forms.
*/
export const getStatusBarSectionForms = (): EditorForm[] => [
{
basePath: ['status_bar'],
schema: [
{
name: 'style',
selector: createSelectSelector([
{ value: 'hover', label: localize('config.status_bar.styles.hover') },
{
value: 'hover-card',
label: localize('config.status_bar.styles.hover-card'),
},
{ value: 'none', label: localize('config.status_bar.styles.none') },
{ value: 'outside', label: localize('config.status_bar.styles.outside') },
{ value: 'overlay', label: localize('config.status_bar.styles.overlay') },
{ value: 'popup', label: localize('config.status_bar.styles.popup') },
]),
},
{
name: 'position',
selector: createSelectSelector([
{ value: 'top', label: localize('config.status_bar.positions.top') },
{ value: 'bottom', label: localize('config.status_bar.positions.bottom') },
]),
},
{
name: 'auto_hide',
selector: createSelectSelector(
[
{
value: 'call',
label: localize('config.common.auto_hide_conditions.call'),
},
{
value: 'casting',
label: localize('config.common.auto_hide_conditions.casting'),
},
],
{ multiple: true },
),
},
{
name: 'height',
selector: createNumberSelector({ min: STATUS_BAR_HEIGHT_MIN }),
},
{
name: 'popup_seconds',
selector: createNumberSelector({ min: 0, max: 60 }),
},
],
},
{
basePath: ['status_bar', 'items'],
schema: STATUS_BAR_ITEMS.map(getStatusBarItemSchema),
},
];
@@ -0,0 +1,20 @@
import type { EditorForm } from '../types';
import { getThumbnailsSchema } from './common/controls/thumbnails';
import { getTimelineCoreSchema } from './common/controls/timeline';
/**
* Get the forms for the timeline section. The core timeline fields and the
* thumbnail controls live under different configuration paths, so they render
* as two forms.
* @returns The section forms.
*/
export const getTimelineSectionForms = (): EditorForm[] => [
{
basePath: ['timeline'],
schema: getTimelineCoreSchema(),
},
{
basePath: ['timeline', 'controls'],
schema: [getThumbnailsSchema({ includeMode: true })],
},
];
+237
View File
@@ -0,0 +1,237 @@
import type { HASelectSelectorOption } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { getInteractionModeOptions } from './common/interaction-mode';
import { createNumberSelector, createSelectSelector } from './common/selectors';
const getViewModeOptions = (): HASelectSelectorOption[] => [
{ value: 'auto', label: localize('config.view.views.auto') },
{ value: 'clip', label: localize('config.view.views.clip') },
{ value: 'clips', label: localize('config.view.views.clips') },
{ value: 'diagnostics', label: localize('config.view.views.diagnostics') },
{ value: 'folder', label: localize('config.view.views.folder') },
{ value: 'folders', label: localize('config.view.views.folders') },
{ value: 'gallery', label: localize('config.view.views.gallery') },
{ value: 'image', label: localize('config.view.views.image') },
{ value: 'live', label: localize('config.view.views.live') },
{ value: 'media', label: localize('config.view.views.media') },
{ value: 'recording', label: localize('config.view.views.recording') },
{ value: 'recordings', label: localize('config.view.views.recordings') },
{ value: 'review', label: localize('config.view.views.review') },
{ value: 'reviews', label: localize('config.view.views.reviews') },
{ value: 'snapshot', label: localize('config.view.views.snapshot') },
{ value: 'snapshots', label: localize('config.view.views.snapshots') },
{ value: 'timeline', label: localize('config.view.views.timeline') },
];
/**
* Get the form for the view section (excluding the keyboard shortcuts, which
* render in their own panel).
* @param options The entity IDs offered for entity fields.
* @returns The section forms.
*/
export const getViewSectionForms = (): EditorForm[] => [
{
basePath: ['view'],
schema: [
{
name: 'default',
selector: createSelectSelector(getViewModeOptions()),
},
{
name: 'camera_select',
selector: createSelectSelector([
...getViewModeOptions(),
{ value: 'current', label: localize('config.view.views.current') },
]),
},
{
name: 'dim',
selector: { boolean: {} },
},
{
name: 'interaction_seconds',
selector: createNumberSelector(),
},
{
name: 'default_cycle_camera',
selector: { boolean: {} },
},
{
name: 'default_reset',
type: 'expandable',
title: localize('config.view.default_reset.editor_label'),
icon: 'mdi:restart',
schema: [
{
name: 'after_interaction',
selector: { boolean: {} },
},
{
name: 'every_seconds',
selector: createNumberSelector(),
},
{
name: 'interaction_mode',
label: localize('config.view.default_reset.interaction_mode'),
selector: createSelectSelector(getInteractionModeOptions()),
},
{
name: 'entities',
selector: { entity: { multiple: true } },
},
],
},
{
name: 'issues',
type: 'expandable',
title: localize('config.view.issues.editor_label'),
icon: 'mdi:alert-circle-outline',
schema: [
{
name: 'interaction_mode',
label: localize('config.view.issues.interaction_mode'),
selector: createSelectSelector(getInteractionModeOptions()),
},
{
name: 'retry_seconds',
selector: createNumberSelector(),
},
],
},
{
name: 'triggers',
type: 'expandable',
title: localize('config.view.triggers.editor_label'),
icon: 'mdi:target-account',
schema: [
{
name: 'filter_selected_camera',
selector: { boolean: {} },
},
{
name: 'show_trigger_status',
selector: { boolean: {} },
},
{
name: 'untrigger_delay_seconds',
selector: createNumberSelector(),
},
{
name: 'untrigger_force_seconds',
selector: createNumberSelector(),
},
{
name: 'event_hold_seconds',
selector: createNumberSelector(),
},
{
name: 'actions',
type: 'expandable',
title: localize('config.view.triggers.actions.editor_label'),
icon: 'mdi:cogs',
schema: [
{
name: 'trigger',
label: localize('config.view.triggers.actions.trigger'),
selector: createSelectSelector([
{
value: 'call',
label: localize('config.view.triggers.actions.triggers.call'),
},
{
value: 'default',
label: localize('config.view.triggers.actions.triggers.default'),
},
{
value: 'live',
label: localize('config.view.triggers.actions.triggers.live'),
},
{
value: 'media',
label: localize('config.view.triggers.actions.triggers.media'),
},
{
value: 'none',
label: localize('config.view.triggers.actions.triggers.none'),
},
{
value: 'update',
label: localize('config.view.triggers.actions.triggers.update'),
},
]),
},
{
name: 'untrigger',
label: localize('config.view.triggers.actions.untrigger'),
selector: createSelectSelector([
{
value: 'call',
label: localize('config.view.triggers.actions.untriggers.call'),
},
{
value: 'default',
label: localize('config.view.triggers.actions.untriggers.default'),
},
{
value: 'none',
label: localize('config.view.triggers.actions.untriggers.none'),
},
]),
},
{
name: 'interaction_mode',
label: localize('config.view.triggers.actions.interaction_mode'),
selector: createSelectSelector(getInteractionModeOptions()),
},
],
},
],
},
],
},
// `themes` is the only configurable key under `view.theme` (`overrides` is
// free-form CSS kept YAML-only), so it renders bare rather than in an
// expandable panel. The separate `EditorForm` exists only to nest it under
// `theme` via its `basePath`.
{
basePath: ['view', 'theme'],
schema: [
{
name: 'themes',
label: localize('config.view.theme.themes.editor_label'),
selector: createSelectSelector(
[
{ value: 'ha', label: localize('config.view.theme.themes.ha') },
{ value: 'dark', label: localize('config.view.theme.themes.dark') },
{ value: 'light', label: localize('config.view.theme.themes.light') },
{
value: 'traditional',
label: localize('config.view.theme.themes.traditional'),
},
],
{ multiple: true },
),
},
],
},
];
/**
* Get the form for the keyboard shortcuts panel. Only the enabled flag is a
* form field: a keystroke cannot be captured by any `ha-form` selector, so each
* shortcut in `PTZ_KEYBOARD_SHORTCUTS` is rendered by a key assigner widget
* instead.
* @returns The section forms.
*/
export const getViewKeyboardShortcutsSectionForms = (): EditorForm[] => [
{
basePath: ['view', 'keyboard_shortcuts'],
schema: [
{
name: 'enabled',
selector: { boolean: {} },
},
],
},
];
@@ -0,0 +1,68 @@
import type { ReactiveController, ReactiveControllerHost, TemplateResult } from 'lit';
import { FormsController, type FormContext, type FormsInput } from './forms-controller';
import { fireEditorIntent } from './intents';
import type { FormRequest } from './schema/registry';
import type { ConfigChange, ConfigPath } from './types';
type SectionEditorControllerHost = ReactiveControllerHost & EventTarget;
/**
* One top-level section of the editor: whether its panel is open, and the
* forms it shows. A section is given the whole configuration, because a
* dropdown in one section may list values set in another, but it never changes
* it: an edit is reported to the editor as an intent.
*/
export class SectionController implements ReactiveController {
private _host: SectionEditorControllerHost;
private _formsController: FormsController;
private _open = false;
// A section's body is built only once it has been opened, and is kept
// afterwards: closing animates, and a section the user never opens never
// builds its forms at all.
private _everOpened = false;
constructor(
host: SectionEditorControllerHost,
renderDocumentation: (path: ConfigPath) => TemplateResult | null,
) {
this._host = host;
this._formsController = new FormsController(
(changes: ConfigChange[]) =>
fireEditorIntent(this._host, { type: 'changes', changes }),
renderDocumentation,
);
host.addController(this);
}
public hostConnected(): void {
// No connection-time work.
}
public setInput(request: FormRequest, input: FormsInput): void {
this._formsController.setInput(request, input);
}
public getContexts(): FormContext[] {
return this._formsController.getContexts();
}
public isOpen(): boolean {
return this._open;
}
public wasEverOpened(): boolean {
return this._everOpened;
}
public setOpen(open: boolean): void {
if (open === this._open) {
return;
}
this._open = open;
this._everOpened = this._everOpened || open;
this._host.requestUpdate();
}
}
+70
View File
@@ -0,0 +1,70 @@
import { getEntityTitle } from '../../ha/get-entity-title';
import type { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import { isRecord, prettifyTitle } from '../../utils/basic';
/**
* Get an editor title for a camera, starting with the most likely to be
* useful attribute and working towards the least useful. The camera manager
* cannot provide a title here as it cannot start with an unparsed and
* unloaded configuration.
* @param index The index of the camera in the cameras array.
* @param cameraConfig The raw camera configuration.
* @param hass The HomeAssistant object.
* @returns A string title.
*/
export const getEditorCameraTitle = (
index: number,
cameraConfig: unknown,
hass?: HomeAssistant,
): string => {
const config = isRecord(cameraConfig) ? cameraConfig : {};
const webrtcCardEntity = isRecord(config.webrtc_card)
? config.webrtc_card.entity
: null;
const frigateCameraName = isRecord(config.frigate) ? config.frigate.camera_name : null;
return (
(typeof config.title === 'string' && config.title) ||
(typeof config.camera_entity === 'string'
? getEntityTitle(hass, config.camera_entity)
: '') ||
(typeof webrtcCardEntity === 'string' && webrtcCardEntity) ||
(typeof frigateCameraName === 'string' && frigateCameraName
? prettifyTitle(frigateCameraName)
: '') ||
(typeof config.id === 'string' && config.id) ||
localize('editor.camera') + ' #' + index
);
};
/**
* Get an editor title for a folder.
* @param index The index of the folder in the folders array.
* @param folderConfig The raw folder configuration.
* @returns A string title.
*/
export const getEditorFolderTitle = (index: number, folderConfig: unknown): string => {
const config = isRecord(folderConfig) ? folderConfig : {};
return (
(typeof config.title === 'string' && config.title) ||
(typeof config.id === 'string' && config.id) ||
localize('common.folder') + ' #' + index
);
};
/**
* Get an editor title for a camera trigger event.
* @param index The index of the event in the events array.
* @param eventConfig The raw event configuration.
* @returns A string title.
*/
export const getEditorTriggerEventTitle = (
index: number,
eventConfig: unknown,
): string => {
const config = isRecord(eventConfig) ? eventConfig : {};
return (
(typeof config.event_type === 'string' && config.event_type) ||
localize('common.event') + ' #' + index
);
};
+79
View File
@@ -0,0 +1,79 @@
import type { RawAdvancedCameraCardConfig } from '../../config/types';
import type { HAFormSchema } from '../../ha/types';
// The location of a value within a configuration. Numbers address array items.
export type ConfigPath = (string | number)[];
// A change to make to a configuration, at an absolute path.
export type ConfigChange =
| { path: ConfigPath; type: 'set'; value: unknown }
| { path: ConfigPath; type: 'delete' };
// Where a form field's value lives in the configuration, for the fields whose
// location is not simply their position within the form.
//
// A field is addressed by `formPath`, its path within the form's own data. By
// default that path is also its path within the configuration (below the form's
// base path), which is what the ordinary section forms rely on. A binding
// overrides that, either by naming a different configuration path, or by
// reading and writing the value itself for a field whose form representation
// differs from the way it is stored.
//
// `formPath` must name a field, not a group: a group's fields are bound
// individually. A `formPath` naming no field of the form binds nothing.
//
// A binding matters only for a form that gathers fields from across the
// configuration rather than editing one subtree; a form bound to a single
// subtree never needs one.
export type FieldBinding = PathFieldBinding | ComputedFieldBinding;
// A field stored at a configuration path of its own, unchanged.
interface PathFieldBinding {
formPath: string[];
configPath: ConfigPath;
}
// A field whose form representation differs from the way it is stored, and
// which therefore reads and writes itself.
interface ComputedFieldBinding {
formPath: string[];
// The value to show for the field, given the whole configuration and the
// configuration defaults.
read: (
config: RawAdvancedCameraCardConfig,
defaults: RawAdvancedCameraCardConfig,
) => unknown;
// The changes that storing a new value for the field represents. The
// defaults are given as well as the configuration so that a field returning
// to its default can be deleted rather than written out: one form field may
// stand for many configuration keys, and writing every one of them would
// fill the configuration with values the user never set.
write: (
value: unknown,
config: RawAdvancedCameraCardConfig,
defaults: RawAdvancedCameraCardConfig,
) => ConfigChange[];
}
export const isComputedFieldBinding = (
binding: FieldBinding,
): binding is ComputedFieldBinding => 'read' in binding;
// One `ha-form` for part of a section. A section splits into more than one of
// these when its fields live at different configuration paths, since each form
// binds to a single base path. For example, the timeline section uses one form
// for its top-level fields (`timeline.*`) and another for its thumbnail
// controls (`timeline.controls.thumbnails.*`).
export interface EditorForm {
// The path of this form's data within the configuration; empty if it edits
// the configuration root.
basePath: ConfigPath;
schema: HAFormSchema[];
// Fields that are not stored where their position in the form implies. A
// form gathering fields from across the configuration (rather than editing
// one subtree) uses these.
bindings?: FieldBinding[];
}
+9 -20
View File
@@ -1,29 +1,18 @@
import type { HassEntity } from 'home-assistant-js-websocket'; import type { HassEntity } from 'home-assistant-js-websocket';
import frigateSVG from '../camera-manager/frigate/assets/frigate.svg';
import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg';
import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg';
import tplinkSVG from '../camera-manager/tplink/assets/tplink.svg';
import type { Icon } from '../config/schema/common/icon'; import type { Icon } from '../config/schema/common/icon';
import { CUSTOM_ICON_NAMES, CUSTOM_ICONSET_PREFIX } from '../ha/custom-icons';
import type { HomeAssistant } from '../ha/types'; import type { HomeAssistant } from '../ha/types';
import irisSVG from '../images/iris.svg';
export class IconController { export class IconController {
public getCustomIcon(icon?: Icon): string | null { public getIconName(icon?: Icon): string | null {
switch (icon?.icon) { const name = icon?.icon ?? null;
case 'frigate':
return frigateSVG; // Bare legacy names (`frigate`) resolve to the same icon as the iconset
case 'motioneye': // form (`advanced-camera-card:frigate`).
return motioneyeSVG; return name && CUSTOM_ICON_NAMES.includes(name)
case 'reolink': ? `${CUSTOM_ICONSET_PREFIX}:${name}`
return reolinkSVG; : name;
case 'tplink':
return tplinkSVG;
case 'iris':
return irisSVG;
default:
return null;
}
} }
public createStateObjectForStateBadge( public createStateObjectForStateBadge(
@@ -13,6 +13,23 @@ export class KeyAssignerController implements ReactiveController {
this._host.addController(this); this._host.addController(this);
} }
/**
* Show the shortcut the assigner has been given. Nothing is reported back:
* the value came from outside, and reporting it would have the owner store a
* shortcut the user never assigned.
* @param value The shortcut, or null when nothing is assigned.
*/
public showValue(value: KeyboardShortcut | null): void {
if (!isEqual(value, this._value)) {
this._value = value;
this._host.requestUpdate();
}
}
/**
* Assign a shortcut, as the user, and report it.
* @param value The shortcut, or null to unassign.
*/
public setValue(value: KeyboardShortcut | null): void { public setValue(value: KeyboardShortcut | null): void {
if (!isEqual(value, this._value)) { if (!isEqual(value, this._value)) {
this._value = value; this._value = value;
+1 -1
View File
@@ -144,7 +144,7 @@ export class MenuButtonController {
private _getIrisButton(config: AdvancedCameraCardConfig): MenuItem { private _getIrisButton(config: AdvancedCameraCardConfig): MenuItem {
return { return {
icon: 'iris', icon: 'advanced-camera-card:iris',
...config.menu.buttons.iris, ...config.menu.buttons.iris,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
title: localize('config.menu.buttons.iris'), title: localize('config.menu.buttons.iris'),
+3 -3
View File
@@ -25,7 +25,7 @@ import type { CameraConfig } from '../../config/schema/cameras';
import type { AdvancedCameraCardView } from '../../config/schema/common/const'; import type { AdvancedCameraCardView } from '../../config/schema/common/const';
import type { ThumbnailsControlBaseConfig } from '../../config/schema/common/controls/thumbnails'; import type { ThumbnailsControlBaseConfig } from '../../config/schema/common/controls/thumbnails';
import type { import type {
TimelineCoreConfig, TimelineCoreComponentConfig,
TimelinePanMode, TimelinePanMode,
} from '../../config/schema/common/controls/timeline'; } from '../../config/schema/common/controls/timeline';
import { configDefaults } from '../../config/schema/types'; import { configDefaults } from '../../config/schema/types';
@@ -67,7 +67,7 @@ interface TimelineControllerOptions {
conditionStateManager?: ConditionStateManagerReadonlyInterface; conditionStateManager?: ConditionStateManagerReadonlyInterface;
foldersManager?: FoldersManager; foldersManager?: FoldersManager;
viewItemManager?: ViewItemManager; viewItemManager?: ViewItemManager;
timelineConfig?: TimelineCoreConfig; timelineConfig?: TimelineCoreComponentConfig;
mini?: boolean; mini?: boolean;
thumbnailConfig?: ThumbnailsControlBaseConfig; thumbnailConfig?: ThumbnailsControlBaseConfig;
query?: UnifiedQuery; query?: UnifiedQuery;
@@ -89,7 +89,7 @@ export class TimelineController {
private _viewItemManager: ViewItemManager | null = null; private _viewItemManager: ViewItemManager | null = null;
private _viewManagerEpoch: ViewManagerEpoch | null = null; private _viewManagerEpoch: ViewManagerEpoch | null = null;
private _timelineConfig: TimelineCoreConfig | null = null; private _timelineConfig: TimelineCoreComponentConfig | null = null;
private _mini = false; private _mini = false;
+237
View File
@@ -0,0 +1,237 @@
import {
html,
LitElement,
nothing,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { FormsInput } from '../../components-lib/editor/forms-controller';
import { ListFormsController } from '../../components-lib/editor/list-forms-controller';
import { ListPagesController } from '../../components-lib/editor/list-pages-controller';
import {
getEditorCameraTitle,
getEditorTriggerEventTitle,
} from '../../components-lib/editor/titles';
import type { ConfigPath } from '../../components-lib/editor/types';
import { CONF_CAMERAS } from '../../config/const';
import type { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import editorExpanderBodyStyle from '../../scss/editor-expander-body.scss';
import { renderDocumentation } from './doc-link';
import { renderForms } from './form';
import './../icon';
import './list';
import './page';
const CAMERAS_PATH: ConfigPath = [CONF_CAMERAS];
// The lists the user can drill into, named so a page holding more than one can
// tell which was entered.
const CAMERAS_LIST = 'cameras';
const EVENTS_LIST = 'events';
// The cameras section's content: the list of cameras, and the editor for
// whichever camera (or trigger event within it) the user opened. One level is
// shown at a time, as Home Assistant's own editors do for the items of a list.
@customElement('advanced-camera-card-editor-cameras')
export class AdvancedCameraCardEditorCameras extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public input?: FormsInput;
private _pagesController = new ListPagesController(this);
private _formsController = new ListFormsController(this, (path) =>
renderDocumentation(path),
);
protected willUpdate(): void {
if (this.input) {
this._formsController.setInput(this.input);
}
}
protected render(): TemplateResult {
const cameras = this._formsController.getList(CAMERAS_PATH);
const [camera, subpage] = this._pagesController.getPath();
// A page naming a camera that no longer exists (the configuration was
// edited elsewhere) shows the list instead.
if (camera === undefined || camera.index >= cameras.length) {
return this._renderList(cameras);
}
// A step below the camera names the sub-list it entered; today the only one
// is the events. The camera page stays mounted (hidden) while an event is
// edited, so its expanded panels are still expanded when the user comes
// back to it, rather than being rebuilt collapsed.
const event = subpage?.list === EVENTS_LIST ? subpage.index : null;
return html`
${this._renderCamera(camera.index, event !== null)}
${event !== null ? this._renderEvent(camera.index, event) : nothing}
`;
}
private _renderList(cameras: readonly unknown[]): TemplateResult {
return html`
<advanced-camera-card-editor-list
itemIcon="mdi:video"
.addLabel=${localize('editor.add_new_camera')}
.items=${cameras.map((camera, index) => ({
title: getEditorCameraTitle(index, camera, this.hass),
}))}
@advanced-camera-card:editor:list:item-edit=${(
ev: CustomEvent<{ index: number }>,
) => this._pagesController.open(CAMERAS_LIST, ev.detail.index)}
@advanced-camera-card:editor:list:item-add=${() => {
this._formsController.addItem(CAMERAS_PATH, {});
this._pagesController.open(CAMERAS_LIST, cameras.length);
}}
@advanced-camera-card:editor:list:item-move=${(
ev: CustomEvent<{ from: number; to: number }>,
) => this._formsController.moveItem(CAMERAS_PATH, ev.detail.from, ev.detail.to)}
@advanced-camera-card:editor:list:item-delete=${(
ev: CustomEvent<{ index: number }>,
) => this._formsController.deleteItem(CAMERAS_PATH, ev.detail.index)}
></advanced-camera-card-editor-list>
`;
}
private _renderCamera(index: number, hidden: boolean): TemplateResult {
const camera = this._formsController.getList(CAMERAS_PATH)[index];
const eventsPath: ConfigPath = [CONF_CAMERAS, index, 'triggers', 'events'];
const events = this._formsController.getList(eventsPath);
return html`
<advanced-camera-card-editor-page
?hidden=${hidden}
.heading=${getEditorCameraTitle(index, camera, this.hass)}
@advanced-camera-card:editor:page:back=${() => this._pagesController.back()}
>
${renderForms(
this.hass,
this._formsController.getFormContexts({ kind: 'camera', index }),
)}
<ha-expansion-panel
outlined
.header=${localize('config.cameras.triggers.editor_label')}
>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: 'mdi:magnify-scan' }}
></advanced-camera-card-icon>
${this._renderContained(html`
${renderDocumentation([CONF_CAMERAS, 'triggers'])}
${renderForms(
this.hass,
this._formsController.getFormContexts({
kind: 'camera-triggers',
cameraIndex: index,
}),
)}
${this._renderEvents(eventsPath, events)}
`)}
</ha-expansion-panel>
</advanced-camera-card-editor-page>
`;
}
// The Home Assistant events the triggers watch for, a group of their own
// within the triggers panel: they are one kind of trigger among the others,
// not a sibling of the whole trigger set.
private _renderEvents(
eventsPath: ConfigPath,
events: readonly unknown[],
): TemplateResult {
return html`
<ha-expansion-panel
outlined
.header=${localize('config.cameras.triggers.events.editor_label')}
>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: 'mdi:home-assistant' }}
></advanced-camera-card-icon>
${this._renderContained(html`
<advanced-camera-card-editor-list
itemIcon="mdi:flash"
.addLabel=${localize('config.cameras.triggers.events.add_new_event')}
.items=${events.map((event, eventIndex) => ({
title: getEditorTriggerEventTitle(eventIndex, event),
}))}
@advanced-camera-card:editor:list:item-edit=${(
ev: CustomEvent<{ index: number }>,
) => this._pagesController.open(EVENTS_LIST, ev.detail.index)}
@advanced-camera-card:editor:list:item-add=${() => {
// An event filter must name an event type. The empty name the new
// item starts with matches nothing until the user fills it in.
this._formsController.addItem(eventsPath, { event_type: '' });
this._pagesController.open(EVENTS_LIST, events.length);
}}
@advanced-camera-card:editor:list:item-move=${(
ev: CustomEvent<{ from: number; to: number }>,
) =>
this._formsController.moveItem(eventsPath, ev.detail.from, ev.detail.to)}
@advanced-camera-card:editor:list:item-delete=${(
ev: CustomEvent<{ index: number }>,
) => this._formsController.deleteItem(eventsPath, ev.detail.index)}
></advanced-camera-card-editor-list>
`)}
</ha-expansion-panel>
`;
}
// A nested panel's expansion and transition events would otherwise reach the
// panel above and disturb the height it animates to, or collapse it.
private _renderContained(content: TemplateResult): TemplateResult {
return html`
<div
class="values"
@transitionend=${this._stopPropagation}
@expanded-will-change=${this._stopPropagation}
@expanded-changed=${this._stopPropagation}
>
${content}
</div>
`;
}
private _stopPropagation(ev: Event): void {
ev.stopPropagation();
}
private _renderEvent(cameraIndex: number, eventIndex: number): TemplateResult {
const eventsPath: ConfigPath = [CONF_CAMERAS, cameraIndex, 'triggers', 'events'];
const event = this._formsController.getList(eventsPath)[eventIndex];
return html`
<advanced-camera-card-editor-page
.heading=${getEditorTriggerEventTitle(eventIndex, event)}
@advanced-camera-card:editor:page:back=${() => this._pagesController.back()}
>
${renderForms(
this.hass,
this._formsController.getFormContexts({
kind: 'camera-event',
cameraIndex,
eventIndex,
}),
)}
</advanced-camera-card-editor-page>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorExpanderBodyStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-cameras': AdvancedCameraCardEditorCameras;
}
}
+66
View File
@@ -0,0 +1,66 @@
import {
html,
LitElement,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { getDocURL } from '../../components-lib/editor/doc-links';
import { localize } from '../../localize/localize';
import editorDocLinkStyle from '../../scss/editor-doc-link.scss';
// A self-styled documentation link row. Carries its own styles so it renders
// correctly when passed into the shadow DOM of Home Assistant elements (e.g. as
// an `ha-form` expandable description), where the editor's stylesheet cannot
// reach.
@customElement('advanced-camera-card-editor-doc-link')
export class AdvancedCameraCardEditorDocLink extends LitElement {
@property({ attribute: false })
public url?: string;
protected render(): TemplateResult | void {
if (!this.url) {
return;
}
return html`
<a
href=${this.url}
target="_blank"
rel="noopener noreferrer"
title=${localize('editor.docs')}
>
<ha-icon icon="mdi:book-open-page-variant"></ha-icon>
<div>${localize('editor.docs')}</div>
<ha-icon icon="mdi:open-in-new"></ha-icon>
</a>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorDocLinkStyle);
}
}
/**
* Render the documentation link for a configuration path.
* @param path The configuration path.
* @returns A rendered template, or null when the path has no documentation.
*/
export const renderDocumentation = (
path: (string | number)[],
): TemplateResult | null => {
const url = getDocURL(path);
return url
? html`<advanced-camera-card-editor-doc-link
.url=${url}
></advanced-camera-card-editor-doc-link>`
: null;
};
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-doc-link': AdvancedCameraCardEditorDocLink;
}
}
+109
View File
@@ -0,0 +1,109 @@
import {
html,
LitElement,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { FormsInput } from '../../components-lib/editor/forms-controller';
import { ListFormsController } from '../../components-lib/editor/list-forms-controller';
import { ListPagesController } from '../../components-lib/editor/list-pages-controller';
import { getEditorFolderTitle } from '../../components-lib/editor/titles';
import type { ConfigPath } from '../../components-lib/editor/types';
import { CONF_FOLDERS } from '../../config/const';
import type { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import editorExpanderBodyStyle from '../../scss/editor-expander-body.scss';
import { renderDocumentation } from './doc-link';
import { renderForms } from './form';
import './../icon';
import './list';
import './page';
const FOLDERS_PATH: ConfigPath = [CONF_FOLDERS];
// The single list this section drills into.
const FOLDERS_LIST = 'folders';
// The folders section's content: the list of folders, each with its own form.
@customElement('advanced-camera-card-editor-folders')
export class AdvancedCameraCardEditorFolders extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public input?: FormsInput;
private _pages = new ListPagesController(this);
private _controller = new ListFormsController(this, (path) =>
renderDocumentation(path),
);
protected willUpdate(): void {
if (this.input) {
this._controller.setInput(this.input);
}
}
protected render(): TemplateResult {
const folders = this._controller.getList(FOLDERS_PATH);
const [folder] = this._pages.getPath();
// A page naming a folder that no longer exists (the configuration was
// edited elsewhere) shows the list instead.
if (folder !== undefined && folder.index < folders.length) {
const index = folder.index;
return html`
<advanced-camera-card-editor-page
.heading=${getEditorFolderTitle(index, folders[index])}
@advanced-camera-card:editor:page:back=${() => this._pages.back()}
>
${renderForms(
this.hass,
this._controller.getFormContexts({ kind: 'folder', index }),
)}
<ha-alert alert-type="info">
${localize('config.folders.ha.path_info')}
</ha-alert>
</advanced-camera-card-editor-page>
`;
}
return html`
<advanced-camera-card-editor-list
itemIcon="mdi:folder"
.addLabel=${localize('editor.add_new_folder')}
.items=${folders.map((folder, folderIndex) => ({
title: getEditorFolderTitle(folderIndex, folder),
}))}
@advanced-camera-card:editor:list:item-edit=${(
ev: CustomEvent<{ index: number }>,
) => this._pages.open(FOLDERS_LIST, ev.detail.index)}
@advanced-camera-card:editor:list:item-add=${() => {
this._controller.addItem(FOLDERS_PATH, {});
this._pages.open(FOLDERS_LIST, folders.length);
}}
@advanced-camera-card:editor:list:item-move=${(
ev: CustomEvent<{ from: number; to: number }>,
) => this._controller.moveItem(FOLDERS_PATH, ev.detail.from, ev.detail.to)}
@advanced-camera-card:editor:list:item-delete=${(
ev: CustomEvent<{ index: number }>,
) => this._controller.deleteItem(FOLDERS_PATH, ev.detail.index)}
></advanced-camera-card-editor-list>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorExpanderBodyStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-folders': AdvancedCameraCardEditorFolders;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { html, type TemplateResult } from 'lit';
import type { FormContext } from '../../components-lib/editor/forms-controller';
import type { HomeAssistant } from '../../ha/types';
/**
* Render the `ha-form`s of a set of form contexts. Each context is passed
* through as it stands: `ha-form` compares its inputs by identity, so
* substituting an equivalent object for any of them re-renders every field.
* @param hass The HomeAssistant object.
* @param contexts The contexts to render.
* @returns A rendered template.
*/
export const renderForms = (
hass: HomeAssistant | undefined,
contexts: FormContext[],
): TemplateResult => {
return html`${contexts.map(
(context) => html`
<ha-form
.hass=${hass}
.data=${context.displayedData}
.schema=${context.form.schema}
.computeLabel=${context.computeLabel}
.computeHelper=${context.computeHelper}
@value-changed=${context.valueChanged}
></ha-form>
`,
)}`;
};
@@ -0,0 +1,86 @@
import {
html,
LitElement,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { getLocalizationKeyForPath } from '../../components-lib/editor/form-labels.js';
import type { FormsInput } from '../../components-lib/editor/forms-controller.js';
import { KeyboardShortcutsController } from '../../components-lib/editor/keyboard-shortcuts-controller.js';
import { CONF_VIEW_KEYBOARD_SHORTCUTS } from '../../config/const.js';
import type { KeyboardShortcut } from '../../config/schema/view.js';
import type { HomeAssistant } from '../../ha/types.js';
import { localize } from '../../localize/localize.js';
import editorKeyboardShortcutsStyle from '../../scss/editor-keyboard-shortcuts.scss';
import { renderDocumentation } from './doc-link.js';
import { renderForms } from './form.js';
import './../icon';
import './../key-assigner';
// The keyboard shortcuts panel. Hand-built rather than an `ha-form` expandable
// because a shortcut is assigned by pressing a key, which no selector can
// express.
@customElement('advanced-camera-card-editor-keyboard-shortcuts')
export class AdvancedCameraCardEditorKeyboardShortcuts extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public input?: FormsInput;
private _controller = new KeyboardShortcutsController(this, (path) =>
renderDocumentation(path),
);
protected willUpdate(): void {
if (this.input) {
this._controller.setInput(this.input);
}
}
protected render(): TemplateResult {
return html`
<ha-expansion-panel outlined>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: 'mdi:keyboard' }}
></advanced-camera-card-icon>
<span slot="header"
>${localize('config.view.keyboard_shortcuts.editor_label')}</span
>
<div class="values" @transitionend=${(ev: Event) => ev.stopPropagation()}>
${renderDocumentation(CONF_VIEW_KEYBOARD_SHORTCUTS.split('.'))}
${renderForms(this.hass, this._controller.getContexts())}
${Object.keys(this._controller.getShortcuts()).map((name) =>
this._renderKeyAssigner(name),
)}
</div>
</ha-expansion-panel>
`;
}
private _renderKeyAssigner(name: string): TemplateResult {
return html`<advanced-camera-card-key-assigner
.label=${localize(
getLocalizationKeyForPath([...CONF_VIEW_KEYBOARD_SHORTCUTS.split('.'), name]),
)}
.value=${this._controller.getShortcuts()[name]}
@value-changed=${(ev: CustomEvent<{ value: KeyboardShortcut | null }>) =>
this._controller.setShortcut(name, ev.detail.value)}
></advanced-camera-card-key-assigner>`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorKeyboardShortcutsStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-keyboard-shortcuts': AdvancedCameraCardEditorKeyboardShortcuts;
}
}
+136
View File
@@ -0,0 +1,136 @@
import {
html,
LitElement,
nothing,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { localize } from '../../localize/localize';
import editorListStyle from '../../scss/editor-list.scss';
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
import './../icon';
// One item of the list: what to call it, and a line below that naming what it
// refers to where the title alone does not say.
export interface EditorListItem {
title: string;
description?: string;
}
// A list of configuration items, rendered as the rows Home Assistant uses for
// the editable lists in its own editors. An item is opened with the row's edit
// control, which reports `item-edit` for the owner to show that item's editor
// in place of the list. Reordering is done by dragging a row's handle.
//
// The events do not bubble beyond the owner: they are composed only as far as
// the list's own host, since the editor above interprets nothing between here
// and itself.
@customElement('advanced-camera-card-editor-list')
export class AdvancedCameraCardEditorList extends LitElement {
// The items, in list order. Their configuration is not passed: the owner
// renders each item's editor when asked.
@property({ attribute: false })
public items: EditorListItem[] = [];
@property()
public itemIcon?: string;
@property()
public addLabel?: string;
protected render(): TemplateResult {
return html`
<ha-sortable
handle-selector=".handle"
draggable-selector=".item"
@item-moved=${(ev: CustomEvent<{ oldIndex: number; newIndex: number }>) => {
ev.stopPropagation();
this._fire('item-move', { from: ev.detail.oldIndex, to: ev.detail.newIndex });
}}
>
<ha-md-list>
${this.items.map((item, index) => this._renderItem(item, index))}
</ha-md-list>
</ha-sortable>
${this._renderAdd()}
`;
}
private _renderItem(item: EditorListItem, index: number): TemplateResult {
return html`
<ha-md-list-item class="item">
<advanced-camera-card-icon
slot="start"
class="handle"
.icon=${{ icon: 'mdi:drag-horizontal-variant' }}
></advanced-camera-card-icon>
<advanced-camera-card-icon
slot="start"
.icon=${{ icon: this.itemIcon }}
></advanced-camera-card-icon>
<div slot="headline" class="title">${item.title}</div>
${item.description
? html`<div slot="supporting-text" class="description">
${item.description}
</div>`
: nothing}
${this._renderControl('mdi:pencil', localize('editor.edit'), () =>
this._fire('item-edit', { index }),
)}
${this._renderControl('mdi:delete', localize('editor.delete'), () =>
this._fire('item-delete', { index }),
)}
</ha-md-list-item>
`;
}
private _renderAdd(): TemplateResult {
return html`
<ha-button
class="add"
appearance="filled"
size="s"
@click=${() => this._fire('item-add', {})}
>
<advanced-camera-card-icon
slot="start"
.icon=${{ icon: 'mdi:plus' }}
></advanced-camera-card-icon>
${this.addLabel}
</ha-button>
`;
}
private _renderControl(
icon: string,
label: string,
action: () => void,
): TemplateResult {
return html`
<ha-icon-button slot="end" .label=${label} title=${label} @click=${action}>
<advanced-camera-card-icon .icon=${{ icon }}></advanced-camera-card-icon>
</ha-icon-button>
`;
}
private _fire(name: string, detail: Record<string, number>): void {
fireAdvancedCameraCardEvent(this, `editor:list:${name}`, detail, {
bubbles: true,
composed: false,
});
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorListStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-list': AdvancedCameraCardEditorList;
}
}
+60
View File
@@ -0,0 +1,60 @@
import {
html,
LitElement,
unsafeCSS,
type CSSResultGroup,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { localize } from '../../localize/localize';
import editorPageStyle from '../../scss/editor-page.scss';
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
// The editor for one item of a list, shown in place of the list itself: a
// heading naming the item, a control to go back to the list, and the item's
// own content.
@customElement('advanced-camera-card-editor-page')
export class AdvancedCameraCardEditorPage extends LitElement {
@property()
public heading?: string;
protected updated(changedProps: PropertyValues): void {
// Opening an item replaces a list the user may have scrolled down into, so
// the new page can begin above the viewport. Bring its top into view. The
// heading changes on the first render and on each further drill-in, which
// is exactly when a fresh page has been shown. The scroll waits a frame so
// the replaced content has been laid out and the target position is final.
if (changedProps.has('heading')) {
requestAnimationFrame(() => this.scrollIntoView({ block: 'start' }));
}
}
protected render(): TemplateResult {
return html`
<div class="header">
<ha-icon-button-prev
.label=${localize('editor.back')}
@click=${() =>
fireAdvancedCameraCardEvent(this, 'editor:page:back', undefined, {
bubbles: true,
composed: false,
})}
></ha-icon-button-prev>
<span class="heading">${this.heading}</span>
</div>
<slot></slot>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorPageStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-page': AdvancedCameraCardEditorPage;
}
}
+123
View File
@@ -0,0 +1,123 @@
import {
html,
LitElement,
nothing,
unsafeCSS,
type CSSResultGroup,
type TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type {
FormContext,
FormsInput,
} from '../../components-lib/editor/forms-controller';
import type { FormRequest } from '../../components-lib/editor/schema/registry';
import { SectionController } from '../../components-lib/editor/section-controller';
import type { HomeAssistant } from '../../ha/types';
import editorSectionStyle from '../../scss/editor-section.scss';
import { renderDocumentation } from './doc-link';
import { renderForms } from './form';
import './../icon';
// One top-level section of the editor: a panel that shows the forms of its part
// of the configuration, and optionally extra custom content.
@customElement('advanced-camera-card-editor-section')
export class AdvancedCameraCardEditorSection extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public request?: FormRequest;
@property({ attribute: false })
public input?: FormsInput;
@property()
public icon?: string;
@property()
public heading?: string;
// The line shown under the heading, describing what the section covers.
@property()
public description?: string;
// The path whose documentation the section links to; the section's own forms
// supply the links for everything within them.
@property({ attribute: false })
public documentationPath?: (string | number)[];
// Content shown after the section's schema forms, of which there may be
// none. Called only once the section has been opened, and in the render that
// opens it, so that the panel measures a height that includes it.
@property({ attribute: false })
public renderCustomContent?: () => TemplateResult;
private _controller = new SectionController(this, (path) => renderDocumentation(path));
protected willUpdate(): void {
if (this.request && this.input) {
this._controller.setInput(this.request, this.input);
}
}
protected render(): TemplateResult {
return html`
<ha-expansion-panel
.outlined=${this._controller.isOpen()}
.header=${this.heading}
.secondary=${this.description}
@expanded-will-change=${(ev: CustomEvent<{ expanded: boolean }>) => {
// Panels nested in the body emit the same event, and it crosses
// shadow boundaries; only this panel's own toggles count.
if (ev.target === ev.currentTarget) {
this._controller.setOpen(ev.detail.expanded);
}
}}
>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: this.icon }}
></advanced-camera-card-icon>
${this._controller.wasEverOpened() ? this._renderBody() : nothing}
</ha-expansion-panel>
`;
}
private _renderBody(): TemplateResult {
// Nested panels' expansion and transition events would otherwise reach the
// panel above and disturb the height it animates to, or collapse it.
return html`
<div
class="values"
@transitionend=${this._stopPropagation}
@expanded-will-change=${this._stopPropagation}
@expanded-changed=${this._stopPropagation}
>
${this.documentationPath ? renderDocumentation(this.documentationPath) : nothing}
${renderForms(this.hass, this._controller.getContexts())}
${this.renderCustomContent?.() ?? nothing}
</div>
`;
}
private _stopPropagation(ev: Event): void {
ev.stopPropagation();
}
public getContexts(): FormContext[] {
return this._controller.getContexts();
}
static get styles(): CSSResultGroup {
return unsafeCSS(editorSectionStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'advanced-camera-card-editor-section': AdvancedCameraCardEditorSection;
}
}
+6 -3
View File
@@ -1,8 +1,7 @@
import { html, unsafeCSS, type CSSResultGroup, type TemplateResult } from 'lit'; import { html, unsafeCSS, type CSSResultGroup, type TemplateResult } from 'lit';
import { customElement } from 'lit/decorators.js'; import { customElement } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import checkCircleSVG from '../../images/check-circle.svg'; import checkCircleIcon from '../../images/check-circle.svg';
import checkStyle from '../../scss/check.scss'; import checkStyle from '../../scss/check.scss';
import { BaseEffectComponent } from './base'; import { BaseEffectComponent } from './base';
@@ -10,7 +9,11 @@ import { BaseEffectComponent } from './base';
export class AdvancedCameraCardEffectCheck extends BaseEffectComponent { export class AdvancedCameraCardEffectCheck extends BaseEffectComponent {
protected render(): TemplateResult { protected render(): TemplateResult {
// Using inline SVG to avoid ha-icon lazy-loading delay on first use. // Using inline SVG to avoid ha-icon lazy-loading delay on first use.
return html`<span class="check">${unsafeHTML(checkCircleSVG)}</span>`; return html`<span class="check">
<svg viewBox=${checkCircleIcon.viewBox} fill="currentColor">
<path d=${checkCircleIcon.path}></path>
</svg>
</span>`;
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
+4 -23
View File
@@ -3,7 +3,6 @@ import {
LitElement, LitElement,
unsafeCSS, unsafeCSS,
type CSSResultGroup, type CSSResultGroup,
type PropertyValues,
type TemplateResult, type TemplateResult,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
@@ -31,27 +30,9 @@ export class AdvancedCameraCardIcon extends LitElement {
public allowOverrideNonActiveStyles = false; public allowOverrideNonActiveStyles = false;
private _controller = new IconController(); private _controller = new IconController();
private _svg: HTMLElement | null = null;
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('icon')) {
const customIcon = this._controller.getCustomIcon(this.icon);
if (customIcon) {
const svgElement = document.createElement('svg');
svgElement.innerHTML = customIcon;
this._svg = svgElement;
} else {
this._svg = null;
}
}
}
protected render(): TemplateResult { protected render(): TemplateResult {
if (this._svg) { const iconName = this._controller.getIconName(this.icon);
// Use SVG objects (rather than <img>) to ensure styling applies
// correctly.
return html`${this._svg}`;
}
if (this.hass && this.icon?.entity) { if (this.hass && this.icon?.entity) {
const stateObj = this._controller.createStateObjectForStateBadge( const stateObj = this._controller.createStateObjectForStateBadge(
this.hass, this.hass,
@@ -65,12 +46,12 @@ export class AdvancedCameraCardIcon extends LitElement {
.stateColor=${this.icon.stateColor ?? true} .stateColor=${this.icon.stateColor ?? true}
.hass=${this.hass} .hass=${this.hass}
.stateObj=${stateObj} .stateObj=${stateObj}
.overrideIcon=${this.icon.icon} .overrideIcon=${iconName ?? undefined}
></state-badge>`; ></state-badge>`;
} }
} }
if (this.icon?.icon) { if (iconName) {
return html`<ha-icon icon="${this.icon.icon}"></ha-icon>`; return html`<ha-icon icon="${iconName}"></ha-icon>`;
} }
if (this.icon?.fallback) { if (this.icon?.fallback) {
return html`<ha-icon icon="${this.icon.fallback}"></ha-icon>`; return html`<ha-icon icon="${this.icon.fallback}"></ha-icon>`;
+3 -1
View File
@@ -27,7 +27,7 @@ export class AdvancedCameraCardKeyAssigner extends LitElement {
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('value')) { if (changedProps.has('value')) {
this._controller.setValue(this.value ?? null); this._controller.showValue(this.value ?? null);
} }
} }
@@ -44,6 +44,7 @@ export class AdvancedCameraCardKeyAssigner extends LitElement {
return html` return html`
<div class="label">${this.label}</div> <div class="label">${this.label}</div>
<div class="buttons">
<ha-button <ha-button
title="${localize('key_assigner.assign')}" title="${localize('key_assigner.assign')}"
aria-label="${localize('key_assigner.assign')}" aria-label="${localize('key_assigner.assign')}"
@@ -71,6 +72,7 @@ export class AdvancedCameraCardKeyAssigner extends LitElement {
</ha-button>` </ha-button>`
: '' : ''
} }
</div>
<div class="key-row"> <div class="key-row">
${this.value?.ctrl ? renderKey(localize('key_assigner.modifiers.ctrl')) : ''} ${this.value?.ctrl ? renderKey(localize('key_assigner.modifiers.ctrl')) : ''}
${this.value?.shift ? renderKey(localize('key_assigner.modifiers.shift')) : ''} ${this.value?.shift ? renderKey(localize('key_assigner.modifiers.shift')) : ''}
+1 -1
View File
@@ -81,7 +81,7 @@ export class AdvancedCameraCardLoading extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return html`<advanced-camera-card-icon return html`<advanced-camera-card-icon
.icon=${{ icon: 'iris' }} .icon=${{ icon: 'advanced-camera-card:iris' }}
></advanced-camera-card-icon ></advanced-camera-card-icon
><span>${getReleaseVersion()}</span>`; ><span>${getReleaseVersion()}</span>`;
} }
+2 -2
View File
@@ -22,7 +22,7 @@ import {
} from '../components-lib/timeline/types'; } from '../components-lib/timeline/types';
import type { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types'; import type { ConditionStateManagerReadonlyInterface } from '../condition-trigger/conditions/types';
import type { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails'; import type { ThumbnailsControlBaseConfig } from '../config/schema/common/controls/thumbnails';
import type { TimelineCoreConfig } from '../config/schema/common/controls/timeline'; import type { TimelineCoreComponentConfig } from '../config/schema/common/controls/timeline';
import type { CardWideConfig } from '../config/schema/types'; import type { CardWideConfig } from '../config/schema/types';
import type { HomeAssistant } from '../ha/types'; import type { HomeAssistant } from '../ha/types';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
@@ -115,7 +115,7 @@ export class AdvancedCameraCardTimelineCore extends LitElement {
public viewManagerEpoch?: ViewManagerEpoch; public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public timelineConfig?: TimelineCoreConfig; public timelineConfig?: TimelineCoreComponentConfig;
@property({ attribute: false }) @property({ attribute: false })
public thumbnailConfig?: ThumbnailsControlBaseConfig; public thumbnailConfig?: ThumbnailsControlBaseConfig;
+159
View File
@@ -0,0 +1,159 @@
// A single namespace shadowing the main config: anything the upgrade cannot
// faithfully convert is recorded here as a failure, intact, at the same path it
// came from (e.g. top-level `automations` -> `__UPGRADE_FAILURE__.automations`)
// for the user to migrate by hand.
export const CONF_UPGRADE_FAILURE = '__UPGRADE_FAILURE__' as const;
// ===========================================================================
// Configuration path constants
// ===========================================================================
export const CONF_AUTOMATIONS = 'automations' as const;
export const CONF_CAMERAS = 'cameras' as const;
const CONF_CAMERAS_GLOBAL = 'cameras_global' as const;
export const CONF_CAMERAS_GLOBAL_LIVE_PROVIDER =
`${CONF_CAMERAS_GLOBAL}.live_provider` as const;
export const CONF_CAMERAS_GLOBAL_TRIGGERS_DOORBELL =
`${CONF_CAMERAS_GLOBAL}.triggers.doorbell` as const;
export const CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS =
`${CONF_CAMERAS_GLOBAL}.image.refresh_seconds` as const;
export const CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT =
`${CONF_CAMERAS_GLOBAL}.dimensions.layout` as const;
export const CONF_CAMERAS_GLOBAL_PTZ = `${CONF_CAMERAS_GLOBAL}.ptz` as const;
export const CONF_ELEMENTS = 'elements' as const;
export const CONF_FOLDERS = 'folders' as const;
const CONF_VIEW = 'view' as const;
export const CONF_VIEW_DIM = `${CONF_VIEW}.dim` as const;
export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` as const;
export const CONF_VIEW_DEFAULT_CYCLE_CAMERA =
`${CONF_VIEW}.default_cycle_camera` as const;
const CONF_VIEW_DEFAULT_RESET = `${CONF_VIEW}.default_reset` as const;
export const CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE =
`${CONF_VIEW_DEFAULT_RESET}.interaction_mode` as const;
export const CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS =
`${CONF_VIEW_DEFAULT_RESET}.every_seconds` as const;
export const CONF_VIEW_DEFAULT_RESET_ENTITIES =
`${CONF_VIEW_DEFAULT_RESET}.entities` as const;
export const CONF_VIEW_KEYBOARD_SHORTCUTS = `${CONF_VIEW}.keyboard_shortcuts` as const;
export const CONF_VIEW_TRIGGERS = `${CONF_VIEW}.triggers` as const;
export const CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS =
`${CONF_VIEW_TRIGGERS}.show_trigger_status` as const;
export const CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA =
`${CONF_VIEW_TRIGGERS}.filter_selected_camera` as const;
export const CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS =
`${CONF_VIEW_TRIGGERS}.untrigger_delay_seconds` as const;
const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE =
`${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER =
`${CONF_VIEW_TRIGGERS_ACTIONS}.trigger` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER =
`${CONF_VIEW_TRIGGERS_ACTIONS}.untrigger` as const;
const CONF_MEDIA_GALLERY = 'media_gallery' as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_details` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_favorite_control` as const;
const CONF_MEDIA_VIEWER = 'media_viewer' as const;
export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as const;
export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const;
export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const;
export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const;
export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP =
`${CONF_MEDIA_VIEWER}.snapshot_click_plays_clip` as const;
export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT =
`${CONF_MEDIA_VIEWER}.transition_effect` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_BUILTIN =
`${CONF_MEDIA_VIEWER}.controls.builtin` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
`${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_details` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE =
`${CONF_MEDIA_VIEWER}.controls.timeline.mode` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE =
`${CONF_MEDIA_VIEWER}.controls.timeline.pan_mode` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS =
`${CONF_MEDIA_VIEWER}.controls.timeline.show_recordings` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE =
`${CONF_MEDIA_VIEWER}.controls.timeline.style` as const;
const CONF_LIVE = 'live' as const;
export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const;
export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const;
export const CONF_LIVE_CONTROLS_BUILTIN = `${CONF_LIVE}.controls.builtin` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
`${CONF_LIVE}.controls.thumbnails.mode` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_LIVE}.controls.thumbnails.show_details` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_MODE =
`${CONF_LIVE}.controls.timeline.mode` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE =
`${CONF_LIVE}.controls.timeline.pan_mode` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS =
`${CONF_LIVE}.controls.timeline.show_recordings` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_STYLE =
`${CONF_LIVE}.controls.timeline.style` as const;
export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const;
export const CONF_LIVE_LAZY_UNLOAD = `${CONF_LIVE}.lazy_unload` as const;
export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
`${CONF_LIVE}.show_image_during_load` as const;
const CONF_TIMELINE = 'timeline' as const;
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
`${CONF_TIMELINE}.controls.thumbnails.mode` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_TIMELINE}.controls.thumbnails.show_details` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const;
const CONF_MENU = 'menu' as const;
export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const;
const CONF_MENU_BUTTONS = `${CONF_MENU}.buttons` as const;
export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU_BUTTONS}.fullscreen` as const;
export const CONF_MENU_BUTTONS_IRIS = `${CONF_MENU_BUTTONS}.iris` as const;
export const CONF_MENU_BUTTONS_PLAY = `${CONF_MENU_BUTTONS}.play` as const;
export const CONF_MENU_BUTTONS_MUTE = `${CONF_MENU_BUTTONS}.mute` as const;
export const CONF_MENU_BUTTONS_MEDIA_PLAYER =
`${CONF_MENU_BUTTONS}.media_player` as const;
export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU_BUTTONS}.timeline` as const;
export const CONF_STATUS_BAR = 'status_bar' as const;
export const CONF_STATUS_BAR_STYLE = `${CONF_STATUS_BAR}.style` as const;
const CONF_DIMENSIONS = 'dimensions' as const;
export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const;
export const CONF_DIMENSIONS_ASPECT_RATIO_MODE =
`${CONF_DIMENSIONS}.aspect_ratio_mode` as const;
export const CONF_DIMENSIONS_HEIGHT = `${CONF_DIMENSIONS}.height` as const;
export const CONF_OVERRIDES = 'overrides' as const;
const CONF_PERFORMANCE = 'performance' as const;
export const CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR = `${CONF_PERFORMANCE}.features.animated_progress_indicator`;
export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS = `${CONF_PERFORMANCE}.features.card_loading_effects`;
export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR = `${CONF_PERFORMANCE}.features.card_loading_indicator`;
export const CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE = `${CONF_PERFORMANCE}.features.media_chunk_size`;
export const CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS = `${CONF_PERFORMANCE}.features.max_simultaneous_engine_requests`;
export const CONF_PERFORMANCE_STYLE_BOX_SHADOW = `${CONF_PERFORMANCE}.style.box_shadow`;
export const CONF_PERFORMANCE_STYLE_BORDER_RADIUS = `${CONF_PERFORMANCE}.style.border_radius`;
export const CONF_PROFILES = 'profiles' as const;
+97 -16
View File
@@ -1,5 +1,6 @@
import { cloneDeep, get, isEqual, set, unset } from 'lodash-es'; import { cloneDeep, get, isEqual, set, unset } from 'lodash-es';
import { arrayify, arrayMove, isRecord } from '../utils/basic';
import { import {
CONF_AUTOMATIONS, CONF_AUTOMATIONS,
CONF_CAMERAS, CONF_CAMERAS,
@@ -22,8 +23,7 @@ import {
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS,
} from '../const'; } from './const';
import { arrayify, isRecord } from '../utils/basic';
import type { Condition } from './schema/condition-trigger/conditions/types'; import type { Condition } from './schema/condition-trigger/conditions/types';
import type { import type {
RawAdvancedCameraCardConfig, RawAdvancedCameraCardConfig,
@@ -70,7 +70,7 @@ export const getConfigValue = (
*/ */
export const deleteConfigValue = ( export const deleteConfigValue = (
obj: RawAdvancedCameraCardConfig, obj: RawAdvancedCameraCardConfig,
path: string, path: string | (string | number)[],
): void => { ): void => {
unset(obj, path); unset(obj, path);
}; };
@@ -84,16 +84,89 @@ export const copyConfig = <T>(obj: T): T => {
return cloneDeep(obj); return cloneDeep(obj);
}; };
/** // Apply an in-place mutation to the array at `arrayPath`, returning whether the
* Given an array path, return a true path. // mutation reported a change.
* @param path The array path (should have a '#'). const mutateConfigArray = (
* @param index The numeric array index to use. obj: RawAdvancedCameraCardConfig,
* @returns The true config path. arrayPath: string | (string | number)[],
*/ mutator: (array: unknown[]) => boolean,
export const getArrayConfigPath = (path: string, index: number): string => { ): boolean => {
return path.replace('#', `[${index.toString()}]`); const array = getConfigValue(obj, arrayPath);
return Array.isArray(array) && mutator(array);
}; };
/**
* Append an item to a configuration array. The array is created when the
* configuration does not have one at that path yet.
* @param obj The configuration to modify.
* @param arrayPath The configuration path of the array.
* @param item The item to append.
* @returns `true` if the configuration was modified.
*/
export const addConfigArrayItem = (
obj: RawAdvancedCameraCardConfig,
arrayPath: string | (string | number)[],
item: unknown,
): boolean => {
const array = getConfigValue(obj, arrayPath);
if (!Array.isArray(array)) {
setConfigValue(obj, arrayPath, [item]);
return true;
}
array.push(item);
return true;
};
/**
* Move an item within a configuration array.
* @param obj The configuration to modify.
* @param arrayPath The configuration path of the array.
* @param fromIndex The index of the item to move.
* @param toIndex The index to move the item to.
* @returns `true` if the configuration was modified.
*/
export const moveConfigArrayItem = (
obj: RawAdvancedCameraCardConfig,
arrayPath: string | (string | number)[],
fromIndex: number,
toIndex: number,
): boolean =>
mutateConfigArray(obj, arrayPath, (array) => {
if (
!Number.isInteger(fromIndex) ||
!Number.isInteger(toIndex) ||
fromIndex === toIndex ||
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= array.length ||
toIndex >= array.length
) {
return false;
}
arrayMove(array, fromIndex, toIndex);
return true;
});
/**
* Delete an item from a configuration array.
* @param obj The configuration to modify.
* @param arrayPath The configuration path of the array.
* @param index The index of the item to delete.
* @returns `true` if the configuration was modified.
*/
export const deleteConfigArrayItem = (
obj: RawAdvancedCameraCardConfig,
arrayPath: string | (string | number)[],
index: number,
): boolean =>
mutateConfigArray(obj, arrayPath, (array) => {
if (!Number.isInteger(index) || index < 0 || index >= array.length) {
return false;
}
array.splice(index, 1);
return true;
});
// ************************************************************************* // *************************************************************************
// Upgrade Related Functions // Upgrade Related Functions
// ************************************************************************* // *************************************************************************
@@ -1424,16 +1497,24 @@ const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown =
// Legacy `triggers.events: string[]` (Frigate engine media-availability filter) // Legacy `triggers.events: string[]` (Frigate engine media-availability filter)
// was renamed to `triggers.media_events` to free up `triggers.events` for the // was renamed to `triggers.media_events` to free up `triggers.events` for the
// new HA-bus-event trigger list (object shape). Distinguish old from new by // new HA-bus-event trigger list (object shape). Distinguish old from new by
// element type: an all-string array is legacy; any non-string element marks // element type: an array holding at least one string is legacy; any non-string
// the new shape and must not be touched. If `media_events` already exists we // element marks the new shape and must not be touched. An empty list says
// refuse to overwrite it -- but we still drop the legacy `events` (otherwise // nothing about which of the two it is, and is valid under the new schema, so
// it would fail the new schema, which expects objects). // it is left alone -- a user who deletes their last event trigger is not
// offered an upgrade that would rename the key underneath them. If
// `media_events` already exists we refuse to overwrite it -- but we still drop
// the legacy `events` (otherwise it would fail the new schema, which expects
// objects).
const triggersEventsToMediaEventsTransform = (triggers: unknown): unknown => { const triggersEventsToMediaEventsTransform = (triggers: unknown): unknown => {
if (!isRecord(triggers)) { if (!isRecord(triggers)) {
return undefined; return undefined;
} }
const events = triggers['events']; const events = triggers['events'];
if (!Array.isArray(events) || events.some((x) => typeof x !== 'string')) { if (
!Array.isArray(events) ||
!events.length ||
events.some((x) => typeof x !== 'string')
) {
return undefined; return undefined;
} }
const result = { ...triggers }; const result = { ...triggers };
+1 -1
View File
@@ -10,7 +10,7 @@ import {
CONF_MENU_BUTTONS_MEDIA_PLAYER, CONF_MENU_BUTTONS_MEDIA_PLAYER,
CONF_MENU_BUTTONS_MUTE, CONF_MENU_BUTTONS_MUTE,
CONF_MENU_BUTTONS_PLAY, CONF_MENU_BUTTONS_PLAY,
} from '../../const.js'; } from '../const.js';
export const CASTING_PROFILE = { export const CASTING_PROFILE = {
[CONF_LIVE_CONTROLS_BUILTIN]: false, [CONF_LIVE_CONTROLS_BUILTIN]: false,
+1 -1
View File
@@ -4,7 +4,7 @@ import {
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS, CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
} from '../../const.js'; } from '../const.js';
export const DOORBELL_PROFILE = { export const DOORBELL_PROFILE = {
// Auto-discover HA `event.*` entities with `device_class: doorbell` on each // Auto-discover HA `event.*` entities with `device_class: doorbell` on each
+1 -1
View File
@@ -40,7 +40,7 @@ import {
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL, CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_TIMELINE_SHOW_RECORDINGS, CONF_TIMELINE_SHOW_RECORDINGS,
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
} from '../../const.js'; } from '../const.js';
export const LOW_PERFORMANCE_PROFILE = { export const LOW_PERFORMANCE_PROFILE = {
// Disable thumbnail carousels. // Disable thumbnail carousels.
+1 -1
View File
@@ -5,7 +5,7 @@ import {
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
} from '../../const.js'; } from '../const.js';
export const SCRUBBING_PROFILE = { export const SCRUBBING_PROFILE = {
[CONF_LIVE_CONTROLS_TIMELINE_MODE]: 'below' as const, [CONF_LIVE_CONTROLS_TIMELINE_MODE]: 'below' as const,
+12 -3
View File
@@ -5,7 +5,6 @@ export const timelineCoreConfigDefault = {
window_seconds: 60 * 60, window_seconds: 60 * 60,
show_recordings: true, show_recordings: true,
style: 'stack' as const, style: 'stack' as const,
pan_mode: 'pan' as const,
format: { format: {
'24h': true, '24h': true,
}, },
@@ -34,21 +33,31 @@ export const timelineCoreConfigSchema = z.object({
.optional() .optional()
.default(timelineCoreConfigDefault.show_recordings), .default(timelineCoreConfigDefault.show_recordings),
style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style), style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style),
pan_mode: timelinePanModeSchema.optional().default(timelineCoreConfigDefault.pan_mode),
format: timelineFormatSchema.optional().default(timelineCoreConfigDefault.format), format: timelineFormatSchema.optional().default(timelineCoreConfigDefault.format),
}); });
export type TimelineCoreConfig = z.infer<typeof timelineCoreConfigSchema>; type TimelineCoreConfig = z.infer<typeof timelineCoreConfigSchema>;
export const miniTimelineConfigDefault = { export const miniTimelineConfigDefault = {
...timelineCoreConfigDefault, ...timelineCoreConfigDefault,
mode: 'none' as const, mode: 'none' as const,
// The non-`pan` modes seek within playing media, so `pan_mode` only applies
// to a mini timeline shown alongside media.
pan_mode: 'pan' as const,
// Mini-timeline defaults to ribbon style. // Mini-timeline defaults to ribbon style.
style: 'ribbon' as const, style: 'ribbon' as const,
}; };
export const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({ export const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({
mode: z.enum(['none', 'above', 'below']).default(miniTimelineConfigDefault.mode), mode: z.enum(['none', 'above', 'below']).default(miniTimelineConfigDefault.mode),
pan_mode: timelinePanModeSchema.optional().default(miniTimelineConfigDefault.pan_mode),
style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style), style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style),
}); });
export type MiniTimelineControlConfig = z.infer<typeof miniTimelineConfigSchema>; export type MiniTimelineControlConfig = z.infer<typeof miniTimelineConfigSchema>;
// The config consumed by the shared `timeline-core` component: the core fields,
// plus the mini-only extras (`pan_mode` etc.) made optional since the full
// timeline omits them.
export type TimelineCoreComponentConfig = TimelineCoreConfig &
Partial<MiniTimelineControlConfig>;
+4 -1
View File
@@ -1,6 +1,9 @@
import { z } from 'zod'; import { z } from 'zod';
import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../../const'; import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
// The largest media fetch chunk size the configuration accepts.
export const MEDIA_CHUNK_SIZE_MAX = 1000;
export const performanceConfigDefault = { export const performanceConfigDefault = {
features: { features: {
+11 -8
View File
@@ -37,14 +37,17 @@ const keyboardShortcutsSchema = z.object({
}); });
export type KeyboardShortcuts = z.infer<typeof keyboardShortcutsSchema>; export type KeyboardShortcuts = z.infer<typeof keyboardShortcutsSchema>;
export type PTZKeyboardShortcutName = export type PTZKeyboardShortcutName = Exclude<keyof KeyboardShortcuts, 'enabled'>;
| 'ptz_down'
| 'ptz_home' export const PTZ_KEYBOARD_SHORTCUTS: readonly PTZKeyboardShortcutName[] = [
| 'ptz_left' 'ptz_left',
| 'ptz_right' 'ptz_right',
| 'ptz_up' 'ptz_up',
| 'ptz_zoom_in' 'ptz_down',
| 'ptz_zoom_out'; 'ptz_zoom_in',
'ptz_zoom_out',
'ptz_home',
];
const interactionModeDefault = 'inactive' as const; const interactionModeDefault = 'inactive' as const;
+8 -479
View File
@@ -1,5 +1,10 @@
// ===========================================================================
// Top level Advanced Camera Card Constants
// ===========================================================================
export const REPO_URL = 'https://github.com/dermotduffy/advanced-camera-card' as const; export const REPO_URL = 'https://github.com/dermotduffy/advanced-camera-card' as const;
export const DOCS_URL = 'https://card.camera' as const; export const DOCS_URL = 'https://card.camera' as const;
export const TROUBLESHOOTING_URL = `${DOCS_URL}/#/troubleshooting` as const; export const TROUBLESHOOTING_URL = `${DOCS_URL}/#/troubleshooting` as const;
export const TROUBLESHOOTING_CONFIG_UPGRADE_URL = export const TROUBLESHOOTING_CONFIG_UPGRADE_URL =
`${TROUBLESHOOTING_URL}?id=configuration-upgrade-available` as const; `${TROUBLESHOOTING_URL}?id=configuration-upgrade-available` as const;
@@ -10,487 +15,11 @@ export const TROUBLESHOOTING_LEGACY_RESOURCE_URL =
export const TROUBLESHOOTING_MEDIA_URL = export const TROUBLESHOOTING_MEDIA_URL =
`${TROUBLESHOOTING_URL}?id=media-unavailable` as const; `${TROUBLESHOOTING_URL}?id=media-unavailable` as const;
export const CONF_AUTOMATIONS = 'automations' as const; // ===========================================================================
// Media Constants
export const CONF_CAMERAS = 'cameras' as const; // ===========================================================================
export const CONF_CAMERAS_ARRAY_ALWAYS_ERROR_IF_ENTITY_UNAVAILABLE =
`${CONF_CAMERAS}.#.always_error_if_entity_unavailable` as const;
export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY =
`${CONF_CAMERAS}.#.camera_entity` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME =
`${CONF_CAMERAS}.#.frigate.camera_name` as const;
export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE =
`${CONF_CAMERAS}.#.capabilities.disable` as const;
export const CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT =
`${CONF_CAMERAS}.#.capabilities.disable_except` as const;
export const CONF_CAMERAS_ARRAY_CAPABILITIES_FORCE =
`${CONF_CAMERAS}.#.capabilities.force` as const;
export const CONF_CAMERAS_ARRAY_CAST_METHOD = `${CONF_CAMERAS}.#.cast.method` as const;
export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH =
`${CONF_CAMERAS}.#.cast.dashboard.dashboard_path` as const;
export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH =
`${CONF_CAMERAS}.#.cast.dashboard.view_path` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO =
`${CONF_CAMERAS}.#.dimensions.aspect_ratio` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_ROTATION =
`${CONF_CAMERAS}.#.dimensions.rotation` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID =
`${CONF_CAMERAS}.#.frigate.client_id` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS =
`${CONF_CAMERAS}.#.frigate.labels` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_URL = `${CONF_CAMERAS}.#.frigate.url` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES =
`${CONF_CAMERAS}.#.frigate.zones` as const;
export const CONF_CAMERAS_ARRAY_GO2RTC_METADATA_FETCH_TIMEOUT_SECONDS =
`${CONF_CAMERAS}.#.go2rtc.metadata_fetch_timeout_seconds` as const;
export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const;
export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM =
`${CONF_CAMERAS}.#.go2rtc.stream` as const;
export const CONF_CAMERAS_ARRAY_GO2RTC_URL = `${CONF_CAMERAS}.#.go2rtc.url` as const;
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const;
export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const;
export const CONF_CAMERAS_ARRAY_IMAGE_ENTITY = `${CONF_CAMERAS}.#.image.entity` as const;
export const CONF_CAMERAS_ARRAY_IMAGE_ENTITY_PARAMETERS =
`${CONF_CAMERAS}.#.image.entity_parameters` as const;
export const CONF_CAMERAS_ARRAY_IMAGE_MODE = `${CONF_CAMERAS}.#.image.mode` as const;
export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS =
`${CONF_CAMERAS}.#.image.refresh_seconds` as const;
export const CONF_CAMERAS_ARRAY_IMAGE_URL = `${CONF_CAMERAS}.#.image.url` as const;
export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN =
`${CONF_CAMERAS}.#.motioneye.images.directory_pattern` as const;
export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN =
`${CONF_CAMERAS}.#.motioneye.images.file_pattern` as const;
export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN =
`${CONF_CAMERAS}.#.motioneye.movies.directory_pattern` as const;
export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN =
`${CONF_CAMERAS}.#.motioneye.movies.file_pattern` as const;
export const CONF_CAMERAS_ARRAY_MOTIONEYE_URL =
`${CONF_CAMERAS}.#.motioneye.url` as const;
export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const;
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY =
`${CONF_CAMERAS}.#.webrtc_card.entity` as const;
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL =
`${CONF_CAMERAS}.#.webrtc_card.url` as const;
export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER =
`${CONF_CAMERAS}.#.live_provider` as const;
export const CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS =
`${CONF_CAMERAS}.#.dependencies.cameras` as const;
export const CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS =
`${CONF_CAMERAS}.#.dependencies.all_cameras` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT =
`${CONF_CAMERAS}.#.dimensions.layout.fit` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X =
`${CONF_CAMERAS}.#.dimensions.layout.pan.x` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y =
`${CONF_CAMERAS}.#.dimensions.layout.pan.y` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X =
`${CONF_CAMERAS}.#.dimensions.layout.position.x` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y =
`${CONF_CAMERAS}.#.dimensions.layout.position.y` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP =
`${CONF_CAMERAS}.#.dimensions.layout.view_box.top` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM =
`${CONF_CAMERAS}.#.dimensions.layout.view_box.bottom` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT =
`${CONF_CAMERAS}.#.dimensions.layout.view_box.left` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT =
`${CONF_CAMERAS}.#.dimensions.layout.view_box.right` as const;
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR =
`${CONF_CAMERAS}.#.dimensions.layout.zoom` as const;
export const CONF_CAMERAS_ARRAY_PROXY_DYNAMIC =
`${CONF_CAMERAS}.#.proxy.dynamic` as const;
export const CONF_CAMERAS_ARRAY_PROXY_LIVE = `${CONF_CAMERAS}.#.proxy.live` as const;
export const CONF_CAMERAS_ARRAY_PROXY_MEDIA = `${CONF_CAMERAS}.#.proxy.media` as const;
export const CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS =
`${CONF_CAMERAS}.#.proxy.ssl_ciphers` as const;
export const CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION =
`${CONF_CAMERAS}.#.proxy.ssl_verification` as const;
export const CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION =
`${CONF_CAMERAS}.#.reolink.media_resolution` as const;
export const CONF_CAMERAS_ARRAY_REOLINK_URL = `${CONF_CAMERAS}.#.reolink.url` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
`${CONF_CAMERAS}.#.triggers.motion` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
`${CONF_CAMERAS}.#.triggers.occupancy` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_DOORBELL =
`${CONF_CAMERAS}.#.triggers.doorbell` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
`${CONF_CAMERAS}.#.triggers.entities` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS =
`${CONF_CAMERAS}.#.triggers.events` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_MEDIA_EVENTS =
`${CONF_CAMERAS}.#.triggers.media_events` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_SEVERITIES =
`${CONF_CAMERAS}.#.triggers.reviews.severities` as const;
export const CONF_CAMERAS_ARRAY_TRIGGERS_REVIEWS_DESCRIPTION =
`${CONF_CAMERAS}.#.triggers.reviews.description` as const;
export const CONF_CAMERAS_ARRAY_MEDIA_TYPE = `${CONF_CAMERAS}.#.media.type` as const;
export const CONF_CAMERAS_ARRAY_MEDIA_EVENTS_TYPE =
`${CONF_CAMERAS}.#.media.events_type` as const;
export const CONF_CAMERAS_ARRAY_MEDIA_REVIEWED =
`${CONF_CAMERAS}.#.media.reviewed` as const;
export const CONF_CAMERAS_ARRAY_MEDIA_FOLDERS =
`${CONF_CAMERAS}.#.media.folders` as const;
const CONF_CAMERAS_GLOBAL = 'cameras_global' as const;
export const CONF_CAMERAS_GLOBAL_LIVE_PROVIDER =
`${CONF_CAMERAS_GLOBAL}.live_provider` as const;
export const CONF_CAMERAS_GLOBAL_TRIGGERS_DOORBELL =
`${CONF_CAMERAS_GLOBAL}.triggers.doorbell` as const;
export const CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS =
`${CONF_CAMERAS_GLOBAL}.image.refresh_seconds` as const;
export const CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT =
`${CONF_CAMERAS_GLOBAL}.dimensions.layout` as const;
export const CONF_CAMERAS_GLOBAL_PTZ = `${CONF_CAMERAS_GLOBAL}.ptz` as const;
export const CONF_ELEMENTS = 'elements' as const;
export const CONF_FOLDERS = 'folders' as const;
export const CONF_FOLDERS_ARRAY_HA_URL = `${CONF_FOLDERS}.#.ha.url` as const;
export const CONF_FOLDERS_ARRAY_ICON = `${CONF_FOLDERS}.#.icon` as const;
export const CONF_FOLDERS_ARRAY_ID = `${CONF_FOLDERS}.#.id` as const;
export const CONF_FOLDERS_ARRAY_TITLE = `${CONF_FOLDERS}.#.title` as const;
export const CONF_FOLDERS_ARRAY_TYPE = `${CONF_FOLDERS}.#.type` as const;
const CONF_VIEW = 'view' as const;
export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const;
export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const;
export const CONF_VIEW_DIM = `${CONF_VIEW}.dim` as const;
export const CONF_VIEW_INTERACTION_SECONDS = `${CONF_VIEW}.interaction_seconds` as const;
export const CONF_VIEW_ISSUES = `${CONF_VIEW}.issues` as const;
export const CONF_VIEW_ISSUES_INTERACTION_MODE =
`${CONF_VIEW_ISSUES}.interaction_mode` as const;
export const CONF_VIEW_ISSUES_RETRY_SECONDS =
`${CONF_VIEW_ISSUES}.retry_seconds` as const;
export const CONF_VIEW_KEYBOARD_SHORTCUTS = `${CONF_VIEW}.keyboard_shortcuts` as const;
export const CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED =
`${CONF_VIEW}.keyboard_shortcuts.enabled` as const;
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_LEFT =
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_left` as const;
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_RIGHT =
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_right` as const;
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP =
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_up` as const;
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN =
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_down` as const;
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN =
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_zoom_in` as const;
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT =
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_zoom_out` as const;
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME =
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_home` as const;
export const CONF_VIEW_DEFAULT_CYCLE_CAMERA =
`${CONF_VIEW}.default_cycle_camera` as const;
export const CONF_VIEW_DEFAULT_RESET = `${CONF_VIEW}.default_reset` as const;
export const CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE =
`${CONF_VIEW_DEFAULT_RESET}.interaction_mode` as const;
export const CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS =
`${CONF_VIEW_DEFAULT_RESET}.every_seconds` as const;
export const CONF_VIEW_DEFAULT_RESET_ENTITIES =
`${CONF_VIEW_DEFAULT_RESET}.entities` as const;
export const CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION =
`${CONF_VIEW_DEFAULT_RESET}.after_interaction` as const;
export const CONF_VIEW_THEME_THEMES = `${CONF_VIEW}.theme.themes` as const;
export const CONF_VIEW_TRIGGERS = `${CONF_VIEW}.triggers` as const;
export const CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS =
`${CONF_VIEW_TRIGGERS}.show_trigger_status` as const;
export const CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA =
`${CONF_VIEW_TRIGGERS}.filter_selected_camera` as const;
export const CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS =
`${CONF_VIEW_TRIGGERS}.untrigger_delay_seconds` as const;
export const CONF_VIEW_TRIGGERS_UNTRIGGER_FORCE_SECONDS =
`${CONF_VIEW_TRIGGERS}.untrigger_force_seconds` as const;
export const CONF_VIEW_TRIGGERS_EVENT_HOLD_SECONDS =
`${CONF_VIEW_TRIGGERS}.event_hold_seconds` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS = `${CONF_VIEW_TRIGGERS}.actions` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE =
`${CONF_VIEW_TRIGGERS_ACTIONS}.interaction_mode` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER =
`${CONF_VIEW_TRIGGERS_ACTIONS}.trigger` as const;
export const CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER =
`${CONF_VIEW_TRIGGERS_ACTIONS}.untrigger` as const;
const CONF_MEDIA_GALLERY = 'media_gallery' as const;
export const CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE =
`${CONF_MEDIA_GALLERY}.controls.filter.mode` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_details` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_download_control` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_favorite_control` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_timeline_control` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_review_control` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.show_info_control` as const;
export const CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE =
`${CONF_MEDIA_GALLERY}.controls.thumbnails.size` as const;
const CONF_MEDIA_VIEWER = 'media_viewer' as const;
export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as const;
export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const;
export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const;
export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const;
export const CONF_MEDIA_VIEWER_DISPLAY_MODE =
`${CONF_MEDIA_VIEWER}.display.mode` as const;
export const CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS =
`${CONF_MEDIA_VIEWER}.display.grid_columns` as const;
export const CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS =
`${CONF_MEDIA_VIEWER}.display.grid_max_columns` as const;
export const CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_POSITION =
`${CONF_MEDIA_VIEWER}.display.grid_selected_position` as const;
export const CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR =
`${CONF_MEDIA_VIEWER}.display.grid_selected_width_factor` as const;
export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const;
export const CONF_MEDIA_VIEWER_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const;
export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP =
`${CONF_MEDIA_VIEWER}.snapshot_click_plays_clip` as const;
export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT =
`${CONF_MEDIA_VIEWER}.transition_effect` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_BUILTIN =
`${CONF_MEDIA_VIEWER}.controls.builtin` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE =
`${CONF_MEDIA_VIEWER}.controls.next_previous.auto_hide` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
`${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
`${CONF_MEDIA_VIEWER}.controls.next_previous.size` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.mode` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_details` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_download_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_favorite_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_timeline_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_review_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.show_info_control` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE =
`${CONF_MEDIA_VIEWER}.controls.thumbnails.size` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_MEDIA_VIEWER}.controls.timeline.clustering_threshold` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H =
`${CONF_MEDIA_VIEWER}.controls.timeline.format.24h` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE =
`${CONF_MEDIA_VIEWER}.controls.timeline.mode` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE =
`${CONF_MEDIA_VIEWER}.controls.timeline.pan_mode` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS =
`${CONF_MEDIA_VIEWER}.controls.timeline.show_recordings` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE =
`${CONF_MEDIA_VIEWER}.controls.timeline.style` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS =
`${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_WHEEL =
`${CONF_MEDIA_VIEWER}.controls.wheel` as const;
export const CONF_MEDIA_VIEWER_ZOOMABLE = `${CONF_MEDIA_VIEWER}.zoomable` as const;
const CONF_LIVE = 'live' as const;
export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const;
export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const;
export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const;
export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const;
export const CONF_LIVE_CONTROLS_BUILTIN = `${CONF_LIVE}.controls.builtin` as const;
export const CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE =
`${CONF_LIVE}.controls.call.button_size` as const;
export const CONF_LIVE_CONTROLS_CALL_LOCK = `${CONF_LIVE}.controls.call.lock` as const;
export const CONF_LIVE_CONTROLS_CALL_RINGTONE_REPEAT =
`${CONF_LIVE}.controls.call.ringtone.repeat` as const;
export const CONF_LIVE_CONTROLS_CALL_RINGTONE_TYPE =
`${CONF_LIVE}.controls.call.ringtone.type` as const;
export const CONF_LIVE_CONTROLS_CALL_RINGTONE_URL =
`${CONF_LIVE}.controls.call.ringtone.url` as const;
export const CONF_LIVE_CONTROLS_CALL_UNANSWERED_TIMEOUT_SECONDS =
`${CONF_LIVE}.controls.call.unanswered_timeout_seconds` as const;
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE =
`${CONF_LIVE}.controls.next_previous.auto_hide` as const;
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE =
`${CONF_LIVE}.controls.next_previous.style` as const;
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE =
`${CONF_LIVE}.controls.next_previous.size` as const;
export const CONF_LIVE_CONTROLS_PTZ_HIDE_HOME =
`${CONF_LIVE}.controls.ptz.hide_home` as const;
export const CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT =
`${CONF_LIVE}.controls.ptz.hide_pan_tilt` as const;
export const CONF_LIVE_CONTROLS_PTZ_HIDE_TYPE =
`${CONF_LIVE}.controls.ptz.hide_type` as const;
export const CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM =
`${CONF_LIVE}.controls.ptz.hide_zoom` as const;
export const CONF_LIVE_CONTROLS_PTZ_MODE = `${CONF_LIVE}.controls.ptz.mode` as const;
export const CONF_LIVE_CONTROLS_PTZ_ORIENTATION =
`${CONF_LIVE}.controls.ptz.orientation` as const;
export const CONF_LIVE_CONTROLS_PTZ_POSITION =
`${CONF_LIVE}.controls.ptz.position` as const;
export const CONF_LIVE_CONTROLS_PTZ_TYPE = `${CONF_LIVE}.controls.ptz.type` as const;
export const CONF_LIVE_CONTROLS_WHEEL = `${CONF_LIVE}.controls.wheel` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
`${CONF_LIVE}.controls.thumbnails.mode` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
`${CONF_LIVE}.controls.thumbnails.size` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_LIVE}.controls.thumbnails.show_details` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_download_control` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_favorite_control` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_timeline_control` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_review_control` as const;
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
`${CONF_LIVE}.controls.thumbnails.show_info_control` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_LIVE}.controls.timeline.clustering_threshold` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H =
`${CONF_LIVE}.controls.timeline.format.24h` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_MODE =
`${CONF_LIVE}.controls.timeline.mode` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE =
`${CONF_LIVE}.controls.timeline.pan_mode` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS =
`${CONF_LIVE}.controls.timeline.show_recordings` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_STYLE =
`${CONF_LIVE}.controls.timeline.style` as const;
export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS =
`${CONF_LIVE}.controls.timeline.window_seconds` as const;
export const CONF_LIVE_DISPLAY_MODE = `${CONF_LIVE}.display.mode` as const;
export const CONF_LIVE_DISPLAY_GRID_COLUMNS =
`${CONF_LIVE}.display.grid_columns` as const;
export const CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS =
`${CONF_LIVE}.display.grid_max_columns` as const;
export const CONF_LIVE_DISPLAY_GRID_SELECTED_POSITION =
`${CONF_LIVE}.display.grid_selected_position` as const;
export const CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR =
`${CONF_LIVE}.display.grid_selected_width_factor` as const;
export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const;
export const CONF_LIVE_LAZY_LOAD = `${CONF_LIVE}.lazy_load` as const;
export const CONF_LIVE_LAZY_UNLOAD = `${CONF_LIVE}.lazy_unload` as const;
export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const;
export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
`${CONF_LIVE}.show_image_during_load` as const;
export const CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED =
`${CONF_LIVE}.microphone.always_connected` as const;
export const CONF_LIVE_MICROPHONE_AUTO_MUTE =
`${CONF_LIVE}.microphone.auto_mute` as const;
export const CONF_LIVE_MICROPHONE_AUTO_UNMUTE =
`${CONF_LIVE}.microphone.auto_unmute` as const;
export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS =
`${CONF_LIVE}.microphone.disconnect_seconds` as const;
export const CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS =
`${CONF_LIVE}.microphone.mute_after_microphone_mute_seconds` as const;
export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const;
const CONF_IMAGE = 'image' as const;
export const CONF_IMAGE_ENTITY = `${CONF_IMAGE}.entity` as const;
export const CONF_IMAGE_ENTITY_PARAMETERS = `${CONF_IMAGE}.entity_parameters` as const;
export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
export const CONF_IMAGE_PROXY_DYNAMIC = `${CONF_IMAGE}.proxy.dynamic` as const;
export const CONF_IMAGE_PROXY_ENABLED = `${CONF_IMAGE}.proxy.enabled` as const;
export const CONF_IMAGE_PROXY_SSL_CIPHERS = `${CONF_IMAGE}.proxy.ssl_ciphers` as const;
export const CONF_IMAGE_PROXY_SSL_VERIFICATION =
`${CONF_IMAGE}.proxy.ssl_verification` as const;
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
const CONF_TIMELINE = 'timeline' as const;
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
`${CONF_TIMELINE}.clustering_threshold` as const;
export const CONF_TIMELINE_FORMAT_24H = `${CONF_TIMELINE}.format.24h` as const;
export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const;
export const CONF_TIMELINE_STYLE = `${CONF_TIMELINE}.style` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE =
`${CONF_TIMELINE}.controls.thumbnails.mode` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE =
`${CONF_TIMELINE}.controls.thumbnails.size` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
`${CONF_TIMELINE}.controls.thumbnails.show_details` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_download_control` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_favorite_control` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_timeline_control` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_review_control` as const;
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL =
`${CONF_TIMELINE}.controls.thumbnails.show_info_control` as const;
const CONF_MENU = 'menu' as const;
export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const;
export const CONF_MENU_AUTO_HIDE = `${CONF_MENU}.auto_hide` as const;
export const CONF_MENU_POSITION = `${CONF_MENU}.position` as const;
export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const;
export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const;
export const CONF_MENU_BUTTONS = `${CONF_MENU}.buttons` as const;
export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU_BUTTONS}.fullscreen` as const;
export const CONF_MENU_BUTTONS_IRIS = `${CONF_MENU_BUTTONS}.iris` as const;
export const CONF_MENU_BUTTONS_PLAY = `${CONF_MENU_BUTTONS}.play` as const;
export const CONF_MENU_BUTTONS_MUTE = `${CONF_MENU_BUTTONS}.mute` as const;
export const CONF_MENU_BUTTONS_MEDIA_PLAYER =
`${CONF_MENU_BUTTONS}.media_player` as const;
export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU_BUTTONS}.timeline` as const;
export const CONF_STATUS_BAR = 'status_bar' as const;
export const CONF_STATUS_BAR_AUTO_HIDE = `${CONF_STATUS_BAR}.auto_hide` as const;
export const CONF_STATUS_BAR_POSITION = `${CONF_STATUS_BAR}.position` as const;
export const CONF_STATUS_BAR_STYLE = `${CONF_STATUS_BAR}.style` as const;
export const CONF_STATUS_BAR_POPUP_SECONDS = `${CONF_STATUS_BAR}.popup_seconds` as const;
export const CONF_STATUS_BAR_HEIGHT = `${CONF_STATUS_BAR}.height` as const;
export const CONF_STATUS_BAR_ITEMS = `${CONF_STATUS_BAR}.items` as const;
// A single namespace shadowing the main config: anything the upgrade cannot
// faithfully convert is recorded here as a failure, intact, at the same path it
// came from (e.g. top-level `automations` -> `__UPGRADE_FAILURE__.automations`)
// for the user to migrate by hand.
export const CONF_UPGRADE_FAILURE = '__UPGRADE_FAILURE__' as const;
const CONF_DIMENSIONS = 'dimensions' as const;
export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const;
export const CONF_DIMENSIONS_ASPECT_RATIO_MODE =
`${CONF_DIMENSIONS}.aspect_ratio_mode` as const;
export const CONF_DIMENSIONS_HEIGHT = `${CONF_DIMENSIONS}.height` as const;
export const CONF_OVERRIDES = 'overrides' as const;
const CONF_PERFORMANCE = 'performance' as const;
export const CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR = `${CONF_PERFORMANCE}.features.animated_progress_indicator`;
export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS = `${CONF_PERFORMANCE}.features.card_loading_effects`;
export const CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR = `${CONF_PERFORMANCE}.features.card_loading_indicator`;
export const CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE = `${CONF_PERFORMANCE}.features.media_chunk_size`;
export const CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS = `${CONF_PERFORMANCE}.features.max_simultaneous_engine_requests`;
export const CONF_PERFORMANCE_PROFILE = `${CONF_PERFORMANCE}.profile`;
export const CONF_PERFORMANCE_STYLE_BOX_SHADOW = `${CONF_PERFORMANCE}.style.box_shadow`;
export const CONF_PERFORMANCE_STYLE_BORDER_RADIUS = `${CONF_PERFORMANCE}.style.border_radius`;
export const CONF_PROFILES = 'profiles' as const;
const CONF_REMOTE_CONTROL = 'remote_control' as const;
export const CONF_REMOTE_CONTROL_ENTITIES_CAMERA =
`${CONF_REMOTE_CONTROL}.entities.camera` as const;
// Taken from https://github.com/home-assistant/frontend/blob/a759767d794f02527d127802831e68d3caf0cb7a/src/data/media-player.ts#L82
export const MEDIA_PLAYER_SUPPORT_TURN_OFF = 256;
export const MEDIA_PLAYER_SUPPORT_STOP = 4096;
export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
// The number of media items to fetch at a time (for clips/snapshot views, and // The number of media items to fetch at a time (for clips/snapshot views, and
// gallery chunks). Smaller values will cause more frequent smaller fetches, but // gallery chunks). Smaller values will cause more frequent smaller fetches, but
// improved rendering performance. // improved rendering performance.
export const MEDIA_CHUNK_SIZE_DEFAULT = 50; export const MEDIA_CHUNK_SIZE_DEFAULT = 50;
export const MEDIA_CHUNK_SIZE_MAX = 1000;
// The name of the exit keyframe defined in `scss/pop-animation.scss`.
export const POP_OUT_ANIMATION_NAME = 'pop-out';
+4 -1
View File
@@ -1,5 +1,8 @@
declare module '*.scss'; declare module '*.scss';
declare module '*.svg'; declare module '*.svg' {
const icon: { path: string; viewBox: string };
export default icon;
}
declare module '*.jpg'; declare module '*.jpg';
declare module 'view' { declare module 'view' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type // eslint-disable-next-line @typescript-eslint/no-empty-object-type
+206 -4020
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
// Registers the card's custom icons as a Home Assistant custom iconset, so
// any `ha-icon` (and the icon picker) can render `advanced-camera-card:<name>`
// icons -- inside or outside the card. The iconset format supports a single
// monochrome SVG path per icon, so each asset is a single-path silhouette,
// with its path data extracted at build time.
import frigateIcon from '../images/icons/frigate.svg';
import irisIcon from '../images/icons/iris.svg';
import motioneyeIcon from '../images/icons/motioneye.svg';
import reolinkIcon from '../images/icons/reolink.svg';
import tplinkIcon from '../images/icons/tplink.svg';
interface CustomIconsetIcon {
path: string;
viewBox?: string;
}
interface CustomIconsetListItem {
name: string;
}
interface CustomIconsetHelpers {
getIcon: (name: string) => Promise<CustomIconsetIcon>;
getIconList: () => Promise<CustomIconsetListItem[]>;
}
declare global {
interface Window {
customIcons?: Record<string, CustomIconsetHelpers>;
}
}
export const CUSTOM_ICONSET_PREFIX = 'advanced-camera-card';
const ICONS: Record<string, CustomIconsetIcon> = {
frigate: frigateIcon,
iris: irisIcon,
motioneye: motioneyeIcon,
reolink: reolinkIcon,
tplink: tplinkIcon,
};
export const CUSTOM_ICON_NAMES = Object.keys(ICONS);
const getIcon = async (name: string): Promise<CustomIconsetIcon> => {
const icon: CustomIconsetIcon | undefined = ICONS[name];
if (!icon) {
throw new Error(`Unknown icon: ${CUSTOM_ICONSET_PREFIX}:${name}`);
}
return icon;
};
const getIconList = async (): Promise<CustomIconsetListItem[]> =>
CUSTOM_ICON_NAMES.map((name) => ({ name }));
/**
* Register the card's custom iconset with Home Assistant. Safe to call more
* than once; an existing registration (e.g. from a second loaded copy of the
* card) is left in place.
*/
export const registerCustomIconset = (): void => {
window.customIcons ??= {};
window.customIcons[CUSTOM_ICONSET_PREFIX] ??= { getIcon, getIconList };
};
+7
View File
@@ -18,17 +18,24 @@ class HomeAssistantElementsLoadError extends AdvancedCameraCardError {
*/ */
export const sideLoadHomeAssistantElements = async (): Promise<void> => { export const sideLoadHomeAssistantElements = async (): Promise<void> => {
const neededElements = [ const neededElements = [
'ha-alert',
'ha-button', 'ha-button',
'ha-camera-stream', 'ha-camera-stream',
'ha-card', 'ha-card',
'ha-combo-box', 'ha-combo-box',
'ha-dropdown-item', 'ha-dropdown-item',
'ha-dropdown', 'ha-dropdown',
'ha-expansion-panel',
'ha-form',
'ha-hls-player', 'ha-hls-player',
'ha-icon-button-prev',
'ha-icon-button', 'ha-icon-button',
'ha-icon', 'ha-icon',
'ha-md-list-item',
'ha-md-list',
'ha-menu-button', 'ha-menu-button',
'ha-selector', 'ha-selector',
'ha-sortable',
'ha-spinner', 'ha-spinner',
'ha-state-icon', 'ha-state-icon',
'ha-web-rtc-player', 'ha-web-rtc-player',
+108
View File
@@ -254,3 +254,111 @@ export const resolvedMediaSchema = z.object({
mime_type: z.string(), mime_type: z.string(),
}); });
export type ResolvedMedia = z.infer<typeof resolvedMediaSchema>; export type ResolvedMedia = z.infer<typeof resolvedMediaSchema>;
// *************************************************************************
// Home Assistant form types.
// *************************************************************************
// Home Assistant does not publish frontend types as a package, so the subset
// of the `ha-form` schema and `ha-selector` configuration shapes used by this
// card are declared here. They must remain structurally compatible with:
// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-form/types.ts
// https://github.com/home-assistant/frontend/blob/dev/src/data/selector.ts
interface HAEntitySelector {
entity: {
domain?: string;
multiple?: boolean;
};
}
export interface HASelectSelectorOption {
value: unknown;
label: string;
}
export interface HASelectSelector {
select: {
mode?: 'dropdown' | 'list';
multiple?: boolean;
custom_value?: boolean;
options: string[] | HASelectSelectorOption[];
};
}
interface HAStringSelector {
text: {
type?: string;
};
}
export interface HANumberSelector {
number: {
min?: number;
max?: number;
mode?: 'box' | 'slider';
step?: number;
};
}
interface HABooleanSelector {
boolean: Record<string, never>;
}
interface HAIconSelector {
icon: Record<string, never>;
}
interface HAObjectSelector {
object: Record<string, never>;
}
type HASelector =
| HABooleanSelector
| HAEntitySelector
| HAIconSelector
| HANumberSelector
| HAObjectSelector
| HASelectSelector
| HAStringSelector;
interface StockHAFormBaseSchema {
name: string;
}
interface StockHAFormSelectorSchema extends StockHAFormBaseSchema {
selector: HASelector;
}
interface StockHAFormExpandableSchema extends StockHAFormBaseSchema {
type: 'expandable';
title?: string;
icon?: string;
schema: readonly HAFormSchema[];
}
// Extensions layered onto the stock shapes; not part of `ha-form` itself.
interface CardHAFormSchemaExtensions {
// Override the standard path-derived label, e.g. to point shared fields at
// `config.common.*` rather than duplicating keys under each section.
label?: string;
}
export interface HAFormSelectorSchema
extends StockHAFormSelectorSchema,
CardHAFormSchemaExtensions {}
export interface HAFormExpandableSchema
extends Omit<StockHAFormExpandableSchema, 'name'>,
CardHAFormSchemaExtensions {
// hass-frontend requires `name`, but `ha-form`'s runtime treats a missing name
// as a flattened, visual-only group (the `!item.name` paths in ha-form.ts).
// Relaxed to optional here for nameless grouping, e.g. an "Engine" group over
// the top-level `frigate`/`motioneye` camera keys.
name?: string;
// Explicit documentation-link key, for a nameless group whose link cannot be
// derived from a configuration path.
docPath?: string;
}
export type HAFormSchema = HAFormSelectorSchema | HAFormExpandableSchema;
+8 -3
View File
@@ -19,7 +19,7 @@
$ convert -strip -interlace Plane -quality 85% -scale 492x277 iris-screensaver-original.jpg iris-screensaver.jpg $ convert -strip -interlace Plane -quality 85% -scale 492x277 iris-screensaver-original.jpg iris-screensaver.jpg
``` ```
## iris.svg / iris-outline.svg ## icons/iris.svg / iris-background.svg
**Link**: https://pictogrammers.com/library/mdi/icon/camera-iris/ **Link**: https://pictogrammers.com/library/mdi/icon/camera-iris/
@@ -27,8 +27,13 @@ $ convert -strip -interlace Plane -quality 85% -scale 492x277 iris-screensaver-o
**Image Formatting Process**: **Image Formatting Process**:
- Outline addded manually - `icons/iris.svg`: single-path variant for the card's custom iconset
- Opacity adjustmented manually - `iris-background.svg`: outline / opacity adjusted manually
## icons/{frigate,motioneye,reolink,tplink}.svg
**Description**: Vendor logos, flattened to single-path monochrome silhouettes
for the card's custom iconset.
## check-circle.svg ## check-circle.svg
+1
View File
@@ -0,0 +1 @@
<svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4.876 22.744c0.087 0.693 0.812 1.164 0.379 1.271-0.433 0.108-2.327-3.437-2.76-4.952-0.433-1.515-0.676-3.22-0.569-4.194 0.108-0.974 5.71-2.49 5.71-2.895 0-0.406-1.813-0.027-1.894-0.352-0.081-0.325 1.369-0.13 1.759-0.65 0.609-0.812 1.38-0.676 1.867-1.109 0.487-0.433 1.082-2.084 1.191-2.787 0.108-0.704-1.082-1.515-1.408-2.057-0.325-0.541 0.704-0.92 3.194-1.948 2.49-1.028 10.391-3.301 10.499-3.031 0.108 0.271-2.598 1.407-4.492 2.111-1.894 0.704-4.979 2.057-4.979 2.435 0 0.379 0.162 0.677 0.054 1.543-0.108 0.866-0.121 1.27-0.325 1.84-0.135 0.379-0.271 1.272 0 2.083 0.216 0.649 0.92 1.137 1.245 1.245 0.271 0.018 1.017 0.043 1.84 0 1.028-0.054 2.49 0.054 2.49 0.325 0 0.271-2.219 0.108-2.219 0.487 0 0.379 2.273-0.054 2.219 0.325-0.054 0.379-1.894 0.216-2.869 0.216-0.779 0-1.93-0.036-2.435-0.216l-0.108 0.378c-0.18 0-0.557 0.108-0.948 0.109-0.487 0-0.514 0.162-1.407 0.162-0.893 0-0.622-0.054-1.434-0.108-0.812-0.054-3.843 2.273-4.384 3.031-0.541 0.758-0.216 2.598-0.216 3.436 0 0.754-0.108 2.435 0 3.302z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.73 15L9.83 21.76C10.53 21.91 11.25 22 12 22c2.4 0 4.6-0.85 6.32-2.25L14.66 13.4M2.46 15c0.92 2.92 3.15 5.26 5.99 6.34L12.12 15m-3.58-3l-3.9-6.75c-1.64 1.75-2.64 4.14-2.64 6.75 0 0.68 0.07 1.35 0.2 2h7.49M21.8 10H14.31L14.6 10.5 19.36 18.75C21 16.97 22 14.6 22 12 22 11.31 21.93 10.64 21.8 10m-0.26-1C20.62 6.07 18.39 3.74 15.55 2.66L11.88 9M9.4 10.5L14.17 2.24c-0.7-0.15-1.42-0.24-2.17-0.24-2.4 0-4.6 0.84-6.32 2.25l3.66 6.35z"/></svg>

After

Width:  |  Height:  |  Size: 524 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3.794 4.615C2.036 6.569 0.97 9.124 0.97 11.959 0.97 18.05 5.909 22.989 12 22.989 18.091 22.989 23.03 18.05 23.03 11.959c0-2.825-1.064-5.393-2.811-7.344-0.835 0.307-1.668 0.664-2.492 1.127 1.595 0.462 2.757 1.939 2.757 3.672 0 2.098-1.708 3.818-3.818 3.818-1.789 0-3.294-1.236-3.712-2.89h-1.908c-0.414 1.655-1.922 2.89-3.712 2.89-2.11 0-3.831-1.72-3.831-3.818 0-1.753 1.21-3.243 2.836-3.686-0.825-0.449-1.676-0.798-2.545-1.113zM12 11.535c0.262 0.976 0.543 1.944 1.697 2.545L12 16.625 10.303 14.08c1.011-0.716 1.478-1.594 1.697-2.545z M7.334 8.141c-0.703 0-1.273 0.57-1.273 1.273 0 0.703 0.57 1.273 1.273 1.272 0.703 0 1.273-0.57 1.272-1.272 0-0.703-0.57-1.273-1.272-1.273z M16.666 8.141c-0.703 0-1.273 0.57-1.272 1.273 0 0.703 0.57 1.273 1.272 1.272 0.703 0 1.273-0.57 1.273-1.272 0-0.703-0.57-1.273-1.273-1.273z M12 0.93c-2.233 0-4.308 0.715-6.045 1.855 1.878 0.201 3.731 0.686 5.833 1.724 2.127-1.084 4.307-1.536 6.27-1.763C16.318 1.598 14.24 0.93 12 0.93Z M19.212 3.302C17.001 3.317 13.981 4.403 11.788 5.406 9.482 4.573 7.116 3.253 4.444 3.316 4.266 3.32 4.095 3.329 3.913 3.342L1.819 3.515 3.807 4.177c2.728 0.889 5.127 1.456 7.239 5.12 0.49 0 1.453 0 1.908 0 2.103-3.643 4.626-4.251 7.225-5.12L22.141 3.541 20.1 3.342c-0.278-0.026-0.572-0.042-0.888-0.04z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17.037 23.965C16.765 23.918 16.562 23.836 16.326 23.68 16.197 23.594 14.544 21.953 12.218 19.602 10.077 17.438 8.22 15.583 8.09 15.479 7.604 15.089 7.088 14.869 6.499 14.802 6.314 14.781 5.079 14.763 3.754 14.763l-2.409 0 0.007-7.394v-7.394l6.204 0c3.98 0 6.359 0.015 6.637 0.041 2.331 0.22 4.365 1.499 5.579 3.501 0.686 1.131 1.021 2.278 1.067 3.655 0.063 1.884-0.574 3.66-1.831 5.1-0.756 0.866-1.861 1.63-2.929 2.025l-0.32 0.118-1.951-1.979-1.951-1.98 1.046-0.023c0.576-0.013 1.1-0.037 1.165-0.053 0.596-0.151 0.95-0.314 1.322-0.611 0.644-0.513 1.046-1.244 1.138-2.067 0.168-1.505-0.771-2.87-2.263-3.29L13.95 4.323 9.798 4.311 5.639 4.298v3.069 3.07l1.419 0c1.628 0.001 1.779 0.019 2.31 0.28 0.31 0.152 0.39 0.219 1.082 0.915 3.027 3.042 12.194 12.308 12.205 12.337 0.016 0.044-5.364 0.04-5.618-0.004z"/></svg>

After

Width:  |  Height:  |  Size: 898 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#ffffff" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path d="M155.984 0.844C182.129 0.121 205.944 8.49 225.371 26.156 244.256 44.245 252.535 69.269 253.25 94.938 252.928 122.316 242.482 146.002 223.625 165.688 204.23 184.488 179.742 191.456 153.25 191.125 148.548 191.084 143.845 191.042 139 191 139 173.18 139 155.36 139 137 146.59 136.67 154.18 136.34 162 136 174.007 134.137 182.944 130.452 190.5 120.625 197.347 110.432 199.293 99.095 197.574 87.004 195.503 77.231 191.277 69.067 183.188 63 172.794 56.502 160.79 55.075 148.844 57.691 137.647 61.45 129.313 67.937 123.715 78.402 120.929 84.403 119.903 89.77 119.684 96.395 119.645 97.478 119.607 98.562 119.568 99.679 119.525 101.043 119.482 102.407 119.438 103.813 119.293 108.164 119.149 112.516 119 117 101.18 117 83.36 117 65 117 64.398 83.9 67.548 55.283 91 30 108.41 12.165 131.231 2.14 155.984 0.844Z M0 135C39.93 135 79.86 135 121 135 121 174.93 121 214.86 121 256 71 256 71 256 66 252 63.855 248.851 63.756 246.259 63.773 242.497 63.773 241.396 63.773 240.295 63.773 239.161 63.784 237.979 63.794 236.796 63.805 235.578 63.808 234.363 63.81 233.148 63.813 231.896 63.824 228.014 63.85 224.132 63.875 220.25 63.885 217.618 63.894 214.987 63.902 212.355 63.924 205.904 63.958 199.452 64 193 63.046 193.01 63.046 193.01 62.073 193.02 55.438 193.087 48.803 193.132 42.168 193.165 39.693 193.18 37.219 193.2 34.745 193.226 31.183 193.263 27.621 193.28 24.059 193.293 22.957 193.308 21.856 193.324 20.721 193.34 14.691 193.341 10.289 192.993 5 190-8.497 175.793 0 153.016 0 135Z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

-49
View File
@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
sodipodi:docname="camera-iris-transparent.svg"
id="svg4"
version="1.1"
viewBox="0 0 24 24"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview6"
pagecolor="#b93e3e"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.60784314"
inkscape:pagecheckerboard="false"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="19.378578"
inkscape:cx="12.100991"
inkscape:cy="12.307405"
inkscape:window-width="1440"
inkscape:window-height="848"
inkscape:window-x="0"
inkscape:window-y="52"
inkscape:window-maximized="0"
inkscape:current-layer="svg4" />
<g
id="g1119"
style="fill-opacity:0.05;fill:#ffffff;stroke-width:1.0015748;stroke-dasharray:none">
<circle
style="display:none;opacity:0.5;fill:#000000;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
id="path170"
cx="12"
cy="12"
inkscape:label="Background"
r="11.25" />
<path
d="M 13.730001,15 9.8300003,21.76 C 10.53,21.91 11.25,22 12,22 c 2.400001,0 4.6,-0.85 6.32,-2.25 L 14.660001,13.4 M 2.4600003,15 c 0.92,2.92 3.15,5.26 5.99,6.34 L 12.12,15 m -3.5799997,-3 -3.9,-6.7499996 c -1.64,1.749999 -2.64,4.1399993 -2.64,6.7499996 0,0.68 0.07,1.35 0.2,2 h 7.49 M 21.8,9.9999997 H 14.310001 L 14.600001,10.5 19.36,18.75 C 21,16.97 22,14.6 22,12 22,11.31 21.93,10.64 21.8,9.9999997 m -0.26,-1 C 20.62,6.0700005 18.39,3.7400002 15.550001,2.6600002 L 11.88,8.9999997 M 9.4000003,10.5 14.170001,2.2400002 c -0.7,-0.15 -1.420001,-0.24 -2.170001,-0.24 -2.3999997,0 -4.5999997,0.84 -6.3199997,2.2500003 l 3.66,6.3499995 z"
id="path2"
inkscape:label="Iris"
style="fill-opacity:1;fill:#ffffff;opacity:1;stroke-width:1.0015748;stroke-dasharray:none" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

+28 -28
View File
@@ -129,6 +129,29 @@
"thumbnails": "Miniatures" "thumbnails": "Miniatures"
} }
}, },
"ptz": {
"editor_label": "PTZ",
"hide_home": "Amaga el control de casa",
"hide_pan_tilt": "Amaga el control d'escombratge i inclinació",
"hide_zoom": "Amaga el control de zoom",
"mode": "Mode",
"modes": {
"off": "Desactivat",
"on": "Activat"
},
"orientation": "Orientació",
"orientations": {
"horizontal": "Horitzontal",
"vertical": "Vertical"
},
"position": "Posició",
"positions": {
"bottom-left": "Inferior esquerra",
"bottom-right": "Inferior dreta",
"top-left": "Superior left",
"top-right": "Superior dreta"
}
},
"thumbnails": { "thumbnails": {
"editor_label": "Miniatures", "editor_label": "Miniatures",
"mode": "Mode de miniatures", "mode": "Mode de miniatures",
@@ -204,6 +227,10 @@
"stack": "Esdeveniments apilats i agrupats" "stack": "Esdeveniments apilats i agrupats"
}, },
"window_seconds": "La durada predeterminada de la visualització de la línia de temps en segons" "window_seconds": "La durada predeterminada de la visualització de la línia de temps en segons"
},
"transition_effects": {
"none": "Sense transició",
"slide": "Transició de diapositives"
} }
}, },
"dimensions": { "dimensions": {
@@ -221,30 +248,7 @@
"auto_play": "Reprodueix automàticament les càmeres en directe", "auto_play": "Reprodueix automàticament les càmeres en directe",
"auto_unmute": "Activa automàticament el so de les càmeres en directe", "auto_unmute": "Activa automàticament el so de les càmeres en directe",
"controls": { "controls": {
"editor_label": "Controls del directe", "editor_label": "Controls del directe"
"ptz": {
"editor_label": "PTZ",
"hide_home": "Amaga el control de casa",
"hide_pan_tilt": "Amaga el control d'escombratge i inclinació",
"hide_zoom": "Amaga el control de zoom",
"mode": "Mode",
"modes": {
"off": "Desactivat",
"on": "Activat"
},
"orientation": "Orientació",
"orientations": {
"horizontal": "Horitzontal",
"vertical": "Vertical"
},
"position": "Posició",
"positions": {
"bottom-left": "Inferior esquerra",
"bottom-right": "Inferior dreta",
"top-left": "Superior left",
"top-right": "Superior dreta"
}
}
}, },
"draggable": "La vista de càmeres en directe es pot arrossegar/lliscar", "draggable": "La vista de càmeres en directe es pot arrossegar/lliscar",
"lazy_load": "Les càmeres en directe es carreguen amb mandra", "lazy_load": "Les càmeres en directe es carreguen amb mandra",
@@ -272,10 +276,6 @@
"lazy_load": "El contingut multimèdia del visualitzador multimèdia es carreguen amb mandra al carrusel", "lazy_load": "El contingut multimèdia del visualitzador multimèdia es carreguen amb mandra al carrusel",
"snapshot_click_plays_clip": "Si feu clic a una instantània, es reprodueix un clip relacionat", "snapshot_click_plays_clip": "Si feu clic a una instantània, es reprodueix un clip relacionat",
"transition_effect": "Efecte de transició del visualitzador multimèdia", "transition_effect": "Efecte de transició del visualitzador multimèdia",
"transition_effects": {
"none": "Sense transició",
"slide": "Transició de diapositives"
},
"zoomable": "El visualitzador de mitjans es pot ampliar/escombrar" "zoomable": "El visualitzador de mitjans es pot ampliar/escombrar"
}, },
"menu": { "menu": {

Some files were not shown because too many files have changed in this diff Show More