Fix manual switch reverting instantly, add weather forecast
- scheduler: "Show X now" was a true one-shot -- reverted on the very next 5s tick, causing the flash-to-X-then-flash-back-to-clock behavior. Now pins for 5 minutes (still preemptable by Navidrome priority, which stays authoritative). Added a "Resume automatic" action/button and status display showing what's pinned and until when. - weather: daily forecast strip (3/5/7 days, selectable in settings) below current conditions, via Open-Meteo's daily= params in the same request. Fixed a bad symbol choice along the way -- DejaVu Sans has no glyph for the partly-cloudy emoji, was rendering as a tofu box; swapped to the plain cloud symbol, verified glyph coverage for the whole WMO code table.
This commit is contained in:
1 parent
bfe4616f20
commit
a19a7ea49e
7 files changed
+147
-40
No files matched your search
+35
-26
@@ -22,6 +22,7 @@ from PIL import Image
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TICK_SECONDS = 5
|
TICK_SECONDS = 5
|
||||||
|
MANUAL_OVERRIDE_SECONDS = 300 # how long "show this now" sticks before falling back to automatic
|
||||||
PREVIEW_PATH = Path(__file__).resolve().parent / "web" / "static" / "preview.png"
|
PREVIEW_PATH = Path(__file__).resolve().parent / "web" / "static" / "preview.png"
|
||||||
|
|
||||||
|
|
||||||
@@ -44,12 +45,12 @@ class Scheduler:
|
|||||||
self.display = display
|
self.display = display
|
||||||
|
|
||||||
self._caches = {screen_id: ScreenCache() for screen_id in screens}
|
self._caches = {screen_id: ScreenCache() for screen_id in screens}
|
||||||
self._current_screen_id: str | None = None
|
|
||||||
self._rotation_index = 0
|
self._rotation_index = 0
|
||||||
self._rotation_switched_at = time.time()
|
self._rotation_switched_at = time.time()
|
||||||
self._last_image_hash: str | None = None
|
self._last_image_hash: str | None = None
|
||||||
|
|
||||||
self._manual_screen: str | None = None
|
self._manual_screen: str | None = None
|
||||||
|
self._manual_until: float = 0.0
|
||||||
self._force_once = False
|
self._force_once = False
|
||||||
|
|
||||||
self._wake = threading.Event()
|
self._wake = threading.Event()
|
||||||
@@ -79,12 +80,24 @@ class Scheduler:
|
|||||||
self._wake.set()
|
self._wake.set()
|
||||||
|
|
||||||
def request_switch(self, screen_id: str):
|
def request_switch(self, screen_id: str):
|
||||||
|
"""Pin a screen for MANUAL_OVERRIDE_SECONDS. Navidrome priority can
|
||||||
|
still preempt it if something starts playing -- that's the one thing
|
||||||
|
this app promises to always show reliably -- but rotation won't."""
|
||||||
self._manual_screen = screen_id
|
self._manual_screen = screen_id
|
||||||
|
self._manual_until = time.time() + MANUAL_OVERRIDE_SECONDS
|
||||||
|
self._wake.set()
|
||||||
|
|
||||||
|
def request_resume_rotation(self):
|
||||||
|
self._manual_screen = None
|
||||||
self._wake.set()
|
self._wake.set()
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
def get_status(self) -> dict:
|
||||||
with self._status_lock:
|
with self._status_lock:
|
||||||
return dict(self._status)
|
status = dict(self._status)
|
||||||
|
manual_active = bool(self._manual_screen) and time.time() < self._manual_until
|
||||||
|
status["manual_screen"] = self._manual_screen if manual_active else None
|
||||||
|
status["manual_until"] = time.strftime("%H:%M:%S", time.localtime(self._manual_until)) if manual_active else None
|
||||||
|
return status
|
||||||
|
|
||||||
# ------------------------------------------------------------- internal
|
# ------------------------------------------------------------- internal
|
||||||
|
|
||||||
@@ -98,18 +111,7 @@ class Scheduler:
|
|||||||
self._wake.clear()
|
self._wake.clear()
|
||||||
|
|
||||||
def _tick(self):
|
def _tick(self):
|
||||||
did_check = False
|
screen_id, state, did_check = self._select_screen()
|
||||||
|
|
||||||
if self._manual_screen:
|
|
||||||
screen_id = self._manual_screen
|
|
||||||
self._manual_screen = None
|
|
||||||
state = self._check(screen_id)
|
|
||||||
did_check = True
|
|
||||||
else:
|
|
||||||
screen_id, state, checked = self._select_screen()
|
|
||||||
did_check = checked
|
|
||||||
|
|
||||||
self._current_screen_id = screen_id
|
|
||||||
screen = self.screens[screen_id]
|
screen = self.screens[screen_id]
|
||||||
settings = self.config.get_screen_settings(screen_id)
|
settings = self.config.get_screen_settings(screen_id)
|
||||||
resolution = self.display.resolution
|
resolution = self.display.resolution
|
||||||
@@ -138,17 +140,22 @@ class Scheduler:
|
|||||||
self.config.save()
|
self.config.save()
|
||||||
|
|
||||||
def _select_screen(self) -> tuple[str, object, bool]:
|
def _select_screen(self) -> tuple[str, object, bool]:
|
||||||
"""Returns (screen_id, state, did_check_happen)."""
|
"""Returns (screen_id, state, did_check_happen).
|
||||||
|
|
||||||
|
Priority order: Navidrome-when-playing (if enabled) always wins --
|
||||||
|
that's the one thing this app promises to show reliably. Then a
|
||||||
|
manual "show this now" pin, while it's still within its window. Then
|
||||||
|
normal rotation.
|
||||||
|
"""
|
||||||
if self.config.get_navidrome_priority() and "navidrome" in self.screens:
|
if self.config.get_navidrome_priority() and "navidrome" in self.screens:
|
||||||
cache = self._caches["navidrome"]
|
screen_id, state, checked = self._poll_screen("navidrome")
|
||||||
navidrome = self.screens["navidrome"]
|
if state is not None:
|
||||||
checked = False
|
return screen_id, state, checked
|
||||||
if cache.is_stale(navidrome.poll_interval):
|
|
||||||
cache.state = self._check("navidrome")
|
if self._manual_screen:
|
||||||
cache.checked_at = time.time()
|
if time.time() < self._manual_until:
|
||||||
checked = True
|
return self._poll_screen(self._manual_screen)
|
||||||
if cache.state is not None:
|
self._manual_screen = None # window expired
|
||||||
return "navidrome", cache.state, checked
|
|
||||||
|
|
||||||
return self._rotation_screen()
|
return self._rotation_screen()
|
||||||
|
|
||||||
@@ -162,8 +169,7 @@ class Scheduler:
|
|||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
# With rotation disabled, just pin to whatever's currently selected --
|
# With rotation disabled, just pin to whatever's currently selected --
|
||||||
# never auto-advance. (Manual "show this screen now" still works via
|
# never auto-advance.
|
||||||
# request_switch(); it just won't stick past the next tick.)
|
|
||||||
if rotation.get("enabled", True):
|
if rotation.get("enabled", True):
|
||||||
interval_seconds = rotation["interval_minutes"] * 60
|
interval_seconds = rotation["interval_minutes"] * 60
|
||||||
if now - self._rotation_switched_at >= interval_seconds:
|
if now - self._rotation_switched_at >= interval_seconds:
|
||||||
@@ -171,6 +177,9 @@ class Scheduler:
|
|||||||
self._rotation_switched_at = now
|
self._rotation_switched_at = now
|
||||||
|
|
||||||
screen_id = screen_ids[self._rotation_index % len(screen_ids)]
|
screen_id = screen_ids[self._rotation_index % len(screen_ids)]
|
||||||
|
return self._poll_screen(screen_id)
|
||||||
|
|
||||||
|
def _poll_screen(self, screen_id: str) -> tuple[str, object, bool]:
|
||||||
cache = self._caches[screen_id]
|
cache = self._caches[screen_id]
|
||||||
screen = self.screens[screen_id]
|
screen = self.screens[screen_id]
|
||||||
checked = False
|
checked = False
|
||||||
|
|||||||
+79
-12
@@ -1,6 +1,7 @@
|
|||||||
"""Current weather via Open-Meteo (free, no API key -- just a lat/long)."""
|
"""Current weather + daily forecast via Open-Meteo (free, no API key)."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
@@ -12,13 +13,16 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
HTTP_TIMEOUT = 10
|
HTTP_TIMEOUT = 10
|
||||||
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||||
|
FORECAST_DAY_CHOICES = (3, 5, 7)
|
||||||
|
|
||||||
|
WEEKDAY_ABBR_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||||
|
|
||||||
# WMO weather codes -> (German description, symbol). Not exhaustive, covers
|
# WMO weather codes -> (German description, symbol). Not exhaustive, covers
|
||||||
# the common cases; unmapped codes fall back to a generic description.
|
# the common cases; unmapped codes fall back to a generic description.
|
||||||
WMO_CODES = {
|
WMO_CODES = {
|
||||||
0: ("Klarer Himmel", "☀"),
|
0: ("Klarer Himmel", "☀"),
|
||||||
1: ("Überwiegend klar", "☀"),
|
1: ("Überwiegend klar", "☀"),
|
||||||
2: ("Teilweise bewölkt", "⛅"),
|
2: ("Teilweise bewölkt", "☁"), # DejaVu Sans has no ⛅ glyph, falls back to a tofu box
|
||||||
3: ("Bedeckt", "☁"),
|
3: ("Bedeckt", "☁"),
|
||||||
45: ("Nebel", "☁"),
|
45: ("Nebel", "☁"),
|
||||||
48: ("Nebel", "☁"),
|
48: ("Nebel", "☁"),
|
||||||
@@ -48,7 +52,7 @@ class WeatherScreen(Screen):
|
|||||||
poll_interval = 1800 # 30 minutes -- weather doesn't need to be fresher
|
poll_interval = 1800 # 30 minutes -- weather doesn't need to be fresher
|
||||||
|
|
||||||
def default_settings(self) -> dict:
|
def default_settings(self) -> dict:
|
||||||
return {"city": "", "latitude": None, "longitude": None}
|
return {"city": "", "latitude": None, "longitude": None, "forecast_days": 3}
|
||||||
|
|
||||||
def geocode(self, city: str):
|
def geocode(self, city: str):
|
||||||
"""Resolve a city name to coordinates via Open-Meteo's free geocoding
|
"""Resolve a city name to coordinates via Open-Meteo's free geocoding
|
||||||
@@ -79,32 +83,64 @@ class WeatherScreen(Screen):
|
|||||||
logger.warning("Weather screen has no latitude/longitude configured")
|
logger.warning("Weather screen has no latitude/longitude configured")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
forecast_days = settings.get("forecast_days", 3)
|
||||||
|
if forecast_days not in FORECAST_DAY_CHOICES:
|
||||||
|
forecast_days = 3
|
||||||
|
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
"https://api.open-meteo.com/v1/forecast",
|
"https://api.open-meteo.com/v1/forecast",
|
||||||
params={"latitude": lat, "longitude": lon, "current_weather": "true"},
|
params={
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lon,
|
||||||
|
"current_weather": "true",
|
||||||
|
"daily": "weather_code,temperature_2m_max,temperature_2m_min",
|
||||||
|
"forecast_days": forecast_days,
|
||||||
|
"timezone": "auto",
|
||||||
|
},
|
||||||
timeout=HTTP_TIMEOUT,
|
timeout=HTTP_TIMEOUT,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
current = response.json().get("current_weather") or {}
|
payload = response.json()
|
||||||
|
|
||||||
|
current = payload.get("current_weather") or {}
|
||||||
if "temperature" not in current:
|
if "temperature" not in current:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
code = int(current.get("weathercode", -1))
|
code = int(current.get("weathercode", -1))
|
||||||
description, symbol = WMO_CODES.get(code, ("Unbekannt", "?"))
|
description, symbol = WMO_CODES.get(code, ("Unbekannt", "?"))
|
||||||
|
|
||||||
|
days = []
|
||||||
|
daily = payload.get("daily") or {}
|
||||||
|
for i, date_str in enumerate(daily.get("time", [])):
|
||||||
|
try:
|
||||||
|
weekday = date.fromisoformat(date_str).weekday()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
day_code = int(daily["weather_code"][i])
|
||||||
|
_, day_symbol = WMO_CODES.get(day_code, ("Unbekannt", "?"))
|
||||||
|
days.append({
|
||||||
|
"label": WEEKDAY_ABBR_DE[weekday],
|
||||||
|
"symbol": day_symbol,
|
||||||
|
"high": daily["temperature_2m_max"][i],
|
||||||
|
"low": daily["temperature_2m_min"][i],
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"temperature": current["temperature"],
|
"temperature": current["temperature"],
|
||||||
"windspeed": current.get("windspeed"),
|
"windspeed": current.get("windspeed"),
|
||||||
"description": description,
|
"description": description,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
|
"days": days,
|
||||||
}
|
}
|
||||||
|
|
||||||
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
|
||||||
width, height = resolution
|
width, height = resolution
|
||||||
image = Image.new("RGB", (width, height), "white")
|
image = Image.new("RGB", (width, height), "white")
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
|
scale = min(width, height)
|
||||||
|
|
||||||
if not state:
|
if not state:
|
||||||
font = load_font(size=int(min(width, height) * 0.06))
|
font = load_font(size=int(scale * 0.06))
|
||||||
msg = "Kein Standort konfiguriert" if not (
|
msg = "Kein Standort konfiguriert" if not (
|
||||||
settings.get("latitude") and settings.get("longitude")
|
settings.get("latitude") and settings.get("longitude")
|
||||||
) else "Wetterdaten nicht verfügbar"
|
) else "Wetterdaten nicht verfügbar"
|
||||||
@@ -112,20 +148,51 @@ class WeatherScreen(Screen):
|
|||||||
draw.text(((width - mw) / 2, height / 2), msg, font=font, fill=(120, 120, 120))
|
draw.text(((width - mw) / 2, height / 2), msg, font=font, fill=(120, 120, 120))
|
||||||
return image
|
return image
|
||||||
|
|
||||||
symbol_font = load_font(size=int(min(width, height) * 0.28))
|
days = state.get("days") or []
|
||||||
temp_font = load_font(size=int(min(width, height) * 0.22), bold=True)
|
has_forecast = bool(days)
|
||||||
desc_font = load_font(size=int(min(width, height) * 0.055))
|
current_h = height * (0.56 if has_forecast else 1.0)
|
||||||
|
|
||||||
|
symbol_font = load_font(size=int(scale * (0.20 if has_forecast else 0.28)))
|
||||||
|
temp_font = load_font(size=int(scale * (0.15 if has_forecast else 0.22)), bold=True)
|
||||||
|
desc_font = load_font(size=int(scale * 0.05))
|
||||||
|
|
||||||
symbol = state["symbol"]
|
symbol = state["symbol"]
|
||||||
sw = text_width(draw, symbol, symbol_font)
|
sw = text_width(draw, symbol, symbol_font)
|
||||||
draw.text(((width - sw) / 2, height * 0.14), symbol, font=symbol_font, fill="black")
|
draw.text(((width - sw) / 2, current_h * 0.08), symbol, font=symbol_font, fill="black")
|
||||||
|
|
||||||
temp_str = f"{round(state['temperature'])}°"
|
temp_str = f"{round(state['temperature'])}°"
|
||||||
tw = text_width(draw, temp_str, temp_font)
|
tw = text_width(draw, temp_str, temp_font)
|
||||||
draw.text(((width - tw) / 2, height * 0.46), temp_str, font=temp_font, fill="black")
|
draw.text(((width - tw) / 2, current_h * 0.42), temp_str, font=temp_font, fill="black")
|
||||||
|
|
||||||
desc = state["description"]
|
desc = state["description"]
|
||||||
dw = text_width(draw, desc, desc_font)
|
dw = text_width(draw, desc, desc_font)
|
||||||
draw.text(((width - dw) / 2, height * 0.68), desc, font=desc_font, fill=(90, 90, 90))
|
draw.text(((width - dw) / 2, current_h * 0.78), desc, font=desc_font, fill=(90, 90, 90))
|
||||||
|
|
||||||
|
if has_forecast:
|
||||||
|
forecast_y = current_h
|
||||||
|
forecast_h = height - forecast_y
|
||||||
|
draw.line((width * 0.06, forecast_y, width * 0.94, forecast_y), fill=(224, 224, 224), width=2)
|
||||||
|
|
||||||
|
col_w = width / len(days)
|
||||||
|
label_font = load_font(size=int(scale * 0.042), bold=True)
|
||||||
|
day_symbol_font = load_font(size=int(scale * 0.085))
|
||||||
|
day_temp_font = load_font(size=int(scale * 0.038))
|
||||||
|
|
||||||
|
for i, day in enumerate(days):
|
||||||
|
cx = col_w * i + col_w / 2
|
||||||
|
y = forecast_y + forecast_h * 0.12
|
||||||
|
|
||||||
|
label = day["label"]
|
||||||
|
lw = text_width(draw, label, label_font)
|
||||||
|
draw.text((cx - lw / 2, y), label, font=label_font, fill=(90, 90, 90))
|
||||||
|
y += label_font.size * 1.5
|
||||||
|
|
||||||
|
dsw = text_width(draw, day["symbol"], day_symbol_font)
|
||||||
|
draw.text((cx - dsw / 2, y), day["symbol"], font=day_symbol_font, fill="black")
|
||||||
|
y += day_symbol_font.size * 1.2
|
||||||
|
|
||||||
|
temps = f"{round(day['high'])}°/{round(day['low'])}°"
|
||||||
|
dtw = text_width(draw, temps, day_temp_font)
|
||||||
|
draw.text((cx - dtw / 2, y), temps, font=day_temp_font, fill=(110, 110, 110))
|
||||||
|
|
||||||
return image
|
return image
|
||||||
+10
@@ -36,6 +36,11 @@ def create_app(config, screens: dict, scheduler) -> Flask:
|
|||||||
scheduler.request_switch(screen_id)
|
scheduler.request_switch(screen_id)
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
@app.route("/actions/resume", methods=["POST"])
|
||||||
|
def resume_rotation():
|
||||||
|
scheduler.request_resume_rotation()
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
@app.route("/settings/navidrome", methods=["GET", "POST"])
|
@app.route("/settings/navidrome", methods=["GET", "POST"])
|
||||||
def settings_navidrome():
|
def settings_navidrome():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -60,6 +65,9 @@ def create_app(config, screens: dict, scheduler) -> Flask:
|
|||||||
city = request.form.get("city", "").strip()
|
city = request.form.get("city", "").strip()
|
||||||
lat_raw = request.form.get("latitude", "").strip()
|
lat_raw = request.form.get("latitude", "").strip()
|
||||||
lon_raw = request.form.get("longitude", "").strip()
|
lon_raw = request.form.get("longitude", "").strip()
|
||||||
|
forecast_days = int(request.form.get("forecast_days", 3) or 3)
|
||||||
|
if forecast_days not in (3, 5, 7):
|
||||||
|
forecast_days = 3
|
||||||
|
|
||||||
if city:
|
if city:
|
||||||
try:
|
try:
|
||||||
@@ -73,6 +81,7 @@ def create_app(config, screens: dict, scheduler) -> Flask:
|
|||||||
"city": result["label"],
|
"city": result["label"],
|
||||||
"latitude": result["latitude"],
|
"latitude": result["latitude"],
|
||||||
"longitude": result["longitude"],
|
"longitude": result["longitude"],
|
||||||
|
"forecast_days": forecast_days,
|
||||||
})
|
})
|
||||||
return redirect(url_for("settings_weather", saved=1))
|
return redirect(url_for("settings_weather", saved=1))
|
||||||
else:
|
else:
|
||||||
@@ -80,6 +89,7 @@ def create_app(config, screens: dict, scheduler) -> Flask:
|
|||||||
"city": "",
|
"city": "",
|
||||||
"latitude": float(lat_raw) if lat_raw else None,
|
"latitude": float(lat_raw) if lat_raw else None,
|
||||||
"longitude": float(lon_raw) if lon_raw else None,
|
"longitude": float(lon_raw) if lon_raw else None,
|
||||||
|
"forecast_days": forecast_days,
|
||||||
})
|
})
|
||||||
return redirect(url_for("settings_weather", saved=1))
|
return redirect(url_for("settings_weather", saved=1))
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
label { display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; }
|
label { display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; }
|
||||||
.hint { font-size: 0.78rem; color: var(--text-muted); margin: 0.35rem 0 0; }
|
.hint { font-size: 0.78rem; color: var(--text-muted); margin: 0.35rem 0 0; }
|
||||||
|
|
||||||
input[type=text], input[type=password], input[type=number] {
|
input[type=text], input[type=password], input[type=number], select, input[type=file] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.6rem 0.7rem;
|
padding: 0.6rem 0.7rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
<span class="badge {{ 'on' if rotation.enabled else 'off' }}">{{ "on" if rotation.enabled else "off" }}</span>
|
<span class="badge {{ 'on' if rotation.enabled else 'off' }}">{{ "on" if rotation.enabled else "off" }}</span>
|
||||||
{{ rotation.screens | join(" → ") }} (every {{ rotation.interval_minutes }} min)
|
{{ rotation.screens | join(" → ") }} (every {{ rotation.interval_minutes }} min)
|
||||||
</dd>
|
</dd>
|
||||||
|
{% if status.manual_screen %}
|
||||||
|
<dt>Manual pin</dt>
|
||||||
|
<dd><span class="badge on">{{ screens[status.manual_screen].display_name }}</span> until {{ status.manual_until }}</dd>
|
||||||
|
{% endif %}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -36,6 +40,11 @@
|
|||||||
<button class="secondary" type="submit">Show {{ screen.display_name }}</button>
|
<button class="secondary" type="submit">Show {{ screen.display_name }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if status.manual_screen %}
|
||||||
|
<form class="inline" method="post" action="{{ url_for('resume_rotation') }}">
|
||||||
|
<button class="secondary" type="submit">Resume automatic</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<div class="section-label">Display options</div>
|
<div class="section-label">Display options</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="fit">Fit</label>
|
<label for="fit">Fit</label>
|
||||||
<select id="fit" name="fit" style="width:100%; padding:0.6rem 0.7rem; font-size:0.95rem; font-family:inherit; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:8px;">
|
<select id="fit" name="fit">
|
||||||
<option value="cover" {{ "selected" if settings.fit == "cover" }}>Cover -- fill the screen, cropping edges</option>
|
<option value="cover" {{ "selected" if settings.fit == "cover" }}>Cover -- fill the screen, cropping edges</option>
|
||||||
<option value="contain" {{ "selected" if settings.fit == "contain" }}>Contain -- show the whole image, letterboxed</option>
|
<option value="contain" {{ "selected" if settings.fit == "contain" }}>Contain -- show the whole image, letterboxed</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -19,6 +19,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-label">Forecast</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="forecast_days">Days to show</label>
|
||||||
|
<select id="forecast_days" name="forecast_days">
|
||||||
|
<option value="3" {{ "selected" if settings.forecast_days == 3 or not settings.forecast_days }}>3 days</option>
|
||||||
|
<option value="5" {{ "selected" if settings.forecast_days == 5 }}>5 days</option>
|
||||||
|
<option value="7" {{ "selected" if settings.forecast_days == 7 }}>7 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="section-label">Or enter coordinates manually</div>
|
<div class="section-label">Or enter coordinates manually</div>
|
||||||
<p class="hint" style="margin: -0.25rem 0 1rem;">Leave the city field above empty to use these instead.</p>
|
<p class="hint" style="margin: -0.25rem 0 1rem;">Leave the city field above empty to use these instead.</p>
|
||||||
|
|||||||
Reference in new issue
Block a user