Add Picture screen: upload a file or point at a URL

New screen alongside clock/weather/navidrome. Upload goes through the web
UI (validated with PIL before accepting, stored under web/static/uploads/,
gitignored), or point it at a URL re-fetched every 30 minutes. Cover/contain
fit modes (new fit_cover/fit_contain helpers in render_utils.py, generalized
from the square-only cover-art cropping already used by the Navidrome
screen), plus the same contrast-boost/grayscale options that screen has.
This commit is contained in:
Honey committed 2026-07-21 22:56:32 +02:00
1 parent 61348be674
commit bfe4616f20
8 files changed
+232 -4

No files matched your search

+55
View File
@@ -2,10 +2,17 @@
import requests
from flask import Flask, redirect, render_template, request, url_for
from PIL import Image
from werkzeug.utils import secure_filename
from screens.picture import UPLOAD_DIR
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp"}
def create_app(config, screens: dict, scheduler) -> Flask:
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 20 MB, generous for a photo
@app.route("/")
def index():
@@ -78,6 +85,54 @@ def create_app(config, screens: dict, scheduler) -> Flask:
return render_template("settings_weather.html", settings=config.get_screen_settings("weather"), error=error)
@app.route("/settings/picture", methods=["GET", "POST"])
def settings_picture():
error = None
if request.method == "POST":
url = request.form.get("image_url", "").strip()
common_updates = {
"fit": request.form.get("fit", "cover"),
"boost_contrast": "boost_contrast" in request.form,
"grayscale": "grayscale" in request.form,
}
uploaded = request.files.get("image")
if uploaded and uploaded.filename:
filename = secure_filename(uploaded.filename)
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in ALLOWED_IMAGE_EXTENSIONS:
error = "Unsupported file type -- use PNG, JPG, GIF, BMP, or WebP."
else:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# Only one picture is ever active -- clear any previous
# upload first so a different extension doesn't leave an
# orphaned file behind.
for old in UPLOAD_DIR.glob("current.*"):
old.unlink()
dest = UPLOAD_DIR / f"current.{ext}"
uploaded.save(dest)
try:
Image.open(dest).verify()
except Exception:
dest.unlink(missing_ok=True)
error = "That file doesn't look like a valid image."
else:
config.update_screen_settings("picture", {
"mode": "upload",
"uploaded_filename": dest.name,
**common_updates,
})
return redirect(url_for("settings_picture", saved=1))
elif url:
config.update_screen_settings("picture", {"mode": "url", "image_url": url, **common_updates})
return redirect(url_for("settings_picture", saved=1))
else:
# Neither a new upload nor a URL change -- just update fit/boost/grayscale.
config.update_screen_settings("picture", common_updates)
return redirect(url_for("settings_picture", saved=1))
return render_template("settings_picture.html", settings=config.get_screen_settings("picture"), error=error)
@app.route("/settings/rotation", methods=["GET", "POST"])
def settings_rotation():
if request.method == "POST":
+1
View File
@@ -198,6 +198,7 @@
<a href="{{ url_for('index') }}" class="{{ 'active' if request.endpoint == 'index' }}">Status</a>
<a href="{{ url_for('settings_navidrome') }}" class="{{ 'active' if request.endpoint == 'settings_navidrome' }}">Navidrome</a>
<a href="{{ url_for('settings_weather') }}" class="{{ 'active' if request.endpoint == 'settings_weather' }}">Weather</a>
<a href="{{ url_for('settings_picture') }}" class="{{ 'active' if request.endpoint == 'settings_picture' }}">Picture</a>
<a href="{{ url_for('settings_rotation') }}" class="{{ 'active' if request.endpoint == 'settings_rotation' }}">Rotation</a>
</nav>
{% if request.args.get('saved') %}
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block content %}
<h1>Picture</h1>
{% if error %}
<div class="toast error">{{ error }}</div>
{% endif %}
{% if settings.mode == "upload" and settings.uploaded_filename %}
<div class="card">
<div class="section-label">Current picture</div>
<div class="preview-wrap">
<img class="preview" src="{{ url_for('static', filename='uploads/' + settings.uploaded_filename) }}" alt="current picture">
</div>
</div>
{% elif settings.mode == "url" and settings.image_url %}
<div class="card">
<div class="section-label">Current picture</div>
<p class="hint">Fetched from a URL: {{ settings.image_url }}</p>
</div>
{% endif %}
<form method="post" enctype="multipart/form-data">
<div class="card">
<div class="section-label">Upload</div>
<div class="field">
<label for="image">Choose a file</label>
<input type="file" id="image" name="image" accept="image/png,image/jpeg,image/gif,image/bmp,image/webp">
<p class="hint">PNG, JPG, GIF, BMP, or WebP. Replaces whatever's currently set.</p>
</div>
</div>
<div class="card">
<div class="section-label">Or use a URL</div>
<div class="field">
<label for="image_url">Image URL</label>
<input type="text" id="image_url" name="image_url" placeholder="https://example.com/photo.jpg">
<p class="hint">Re-fetched every 30 minutes. Leave both this and the upload above alone to just change the options below.</p>
</div>
</div>
<div class="card">
<div class="section-label">Display options</div>
<div class="field">
<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;">
<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>
</select>
</div>
<label class="checkbox"><input type="checkbox" name="boost_contrast" {{ "checked" if settings.boost_contrast }}> Boost contrast for e-ink</label>
<label class="checkbox"><input type="checkbox" name="grayscale" {{ "checked" if settings.grayscale }}> Force grayscale</label>
</div>
<button type="submit">Save</button>
</form>
{% endblock %}