Initial commit: Paperwall, a standalone e-ink dashboard
Navidrome now-playing, clock, and weather for a Waveshare epd4in0e panel, with a local web UI and install/uninstall scripts for the Pi.
This commit is contained in:
commit
66464291e1
33 files changed
+2215
No files matched your search
Whitespace-only changes.
+76
@@ -0,0 +1,76 @@
|
||||
"""Flask app: status/preview page + one settings form per screen + rotation settings."""
|
||||
|
||||
from flask import Flask, redirect, render_template, request, url_for
|
||||
|
||||
|
||||
def create_app(config, screens: dict, scheduler) -> Flask:
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
status = scheduler.get_status()
|
||||
return render_template(
|
||||
"index.html",
|
||||
status=status,
|
||||
screens=screens,
|
||||
rotation=config.get_rotation(),
|
||||
navidrome_priority=config.get_navidrome_priority(),
|
||||
)
|
||||
|
||||
@app.route("/actions/refresh", methods=["POST"])
|
||||
def refresh_now():
|
||||
scheduler.request_manual_refresh()
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@app.route("/actions/switch/<screen_id>", methods=["POST"])
|
||||
def switch_now(screen_id):
|
||||
if screen_id in screens:
|
||||
scheduler.request_switch(screen_id)
|
||||
return redirect(url_for("index"))
|
||||
|
||||
@app.route("/settings/navidrome", methods=["GET", "POST"])
|
||||
def settings_navidrome():
|
||||
if request.method == "POST":
|
||||
config.update_screen_settings("navidrome", {
|
||||
"url": request.form.get("url", "").strip(),
|
||||
"username": request.form.get("username", "").strip(),
|
||||
"password": request.form.get("password", ""),
|
||||
"filter_user": request.form.get("filter_user", "").strip(),
|
||||
"show_progress": "show_progress" in request.form,
|
||||
"show_genre": "show_genre" in request.form,
|
||||
"show_source": "show_source" in request.form,
|
||||
"boost_cover": "boost_cover" in request.form,
|
||||
"grayscale_cover": "grayscale_cover" in request.form,
|
||||
})
|
||||
return redirect(url_for("settings_navidrome"))
|
||||
return render_template("settings_navidrome.html", settings=config.get_screen_settings("navidrome"))
|
||||
|
||||
@app.route("/settings/weather", methods=["GET", "POST"])
|
||||
def settings_weather():
|
||||
if request.method == "POST":
|
||||
lat = request.form.get("latitude", "").strip()
|
||||
lon = request.form.get("longitude", "").strip()
|
||||
config.update_screen_settings("weather", {
|
||||
"latitude": float(lat) if lat else None,
|
||||
"longitude": float(lon) if lon else None,
|
||||
})
|
||||
return redirect(url_for("settings_weather"))
|
||||
return render_template("settings_weather.html", settings=config.get_screen_settings("weather"))
|
||||
|
||||
@app.route("/settings/rotation", methods=["GET", "POST"])
|
||||
def settings_rotation():
|
||||
if request.method == "POST":
|
||||
raw = request.form.get("screens", "")
|
||||
selected = [s.strip() for s in raw.split(",") if s.strip() in screens]
|
||||
interval = int(request.form.get("interval_seconds", 300) or 300)
|
||||
config.set_rotation(selected, interval)
|
||||
config.set_navidrome_priority("navidrome_priority" in request.form)
|
||||
return redirect(url_for("settings_rotation"))
|
||||
return render_template(
|
||||
"settings_rotation.html",
|
||||
screens=screens,
|
||||
rotation=config.get_rotation(),
|
||||
navidrome_priority=config.get_navidrome_priority(),
|
||||
)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Paperwall</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; color: #222; }
|
||||
.brand { font-weight: 700; font-size: 1.1rem; margin-bottom: 1rem; }
|
||||
nav { display: flex; gap: 1rem; margin-bottom: 1.5rem; font-size: 0.9rem; }
|
||||
nav a { color: #444; text-decoration: none; }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
h1 { font-size: 1.3rem; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
label { display: block; font-weight: 600; margin-bottom: 0.25rem; font-size: 0.9rem; }
|
||||
input[type=text], input[type=password], input[type=number] {
|
||||
width: 100%; padding: 0.5rem; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px;
|
||||
}
|
||||
.checkbox { display: flex; align-items: center; gap: 0.5rem; font-weight: normal; margin-bottom: 0.5rem; }
|
||||
.checkbox input { width: auto; }
|
||||
button { padding: 0.5rem 1rem; border: 0; border-radius: 4px; background: #222; color: white; cursor: pointer; }
|
||||
button.secondary { background: #eee; color: #222; }
|
||||
.preview { border: 1px solid #ccc; max-width: 100%; }
|
||||
.status-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; font-size: 0.9rem; margin: 1rem 0; }
|
||||
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1rem 0; }
|
||||
form.inline { display: inline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="brand">Paperwall</div>
|
||||
<nav>
|
||||
<a href="{{ url_for('index') }}">Status</a>
|
||||
<a href="{{ url_for('settings_navidrome') }}">Navidrome</a>
|
||||
<a href="{{ url_for('settings_weather') }}">Weather</a>
|
||||
<a href="{{ url_for('settings_rotation') }}">Rotation</a>
|
||||
</nav>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Status</h1>
|
||||
|
||||
<img class="preview" src="{{ url_for('static', filename='preview.png') }}?t={{ status.last_render_at }}" alt="preview">
|
||||
|
||||
<div class="status-grid">
|
||||
<div>Current screen</div><div>{{ status.current_screen or "-" }}</div>
|
||||
<div>Last render</div><div>{{ status.last_render_at or "-" }}</div>
|
||||
<div>Last panel write</div><div>{{ status.last_display_write_at or "-" }}</div>
|
||||
<div>Navidrome priority</div><div>{{ "on" if navidrome_priority else "off" }}</div>
|
||||
<div>Rotation</div><div>{{ rotation.screens | join(" → ") }} (every {{ rotation.interval_seconds }}s)</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<form class="inline" method="post" action="{{ url_for('refresh_now') }}">
|
||||
<button type="submit">Refresh now</button>
|
||||
</form>
|
||||
{% for screen_id, screen in screens.items() %}
|
||||
<form class="inline" method="post" action="{{ url_for('switch_now', screen_id=screen_id) }}">
|
||||
<button class="secondary" type="submit">Show {{ screen.display_name }}</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Navidrome</h1>
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="url">Server URL</label>
|
||||
<input type="text" id="url" name="url" value="{{ settings.url or '' }}" placeholder="https://music.example.com">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" value="{{ settings.username or '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" value="{{ settings.password or '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="filter_user">Only show this listener (optional)</label>
|
||||
<input type="text" id="filter_user" name="filter_user" value="{{ settings.filter_user or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Display options</label>
|
||||
<label class="checkbox"><input type="checkbox" name="show_progress" {{ "checked" if settings.show_progress }}> Progress bar and times</label>
|
||||
<label class="checkbox"><input type="checkbox" name="show_genre" {{ "checked" if settings.show_genre }}> Genre</label>
|
||||
<label class="checkbox"><input type="checkbox" name="show_source" {{ "checked" if settings.show_source }}> Player and listener name</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Cover art</label>
|
||||
<label class="checkbox"><input type="checkbox" name="boost_cover" {{ "checked" if settings.boost_cover }}> Boost contrast for e-ink</label>
|
||||
<label class="checkbox"><input type="checkbox" name="grayscale_cover" {{ "checked" if settings.grayscale_cover }}> Force grayscale</label>
|
||||
</div>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Rotation</h1>
|
||||
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label>Navidrome takes over when music plays</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="navidrome_priority" {{ "checked" if navidrome_priority }}>
|
||||
Show Navidrome immediately whenever something is playing, regardless of rotation
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="screens">Rotation order (used when Navidrome isn't playing, or if priority is off)</label>
|
||||
<input type="text" id="screens" name="screens" value="{{ rotation.screens | join(', ') }}" placeholder="clock, weather">
|
||||
<p style="font-size:0.8rem;color:#666">
|
||||
Comma-separated, in order. Available: {{ screens.keys() | join(', ') }}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="interval_seconds">Rotation interval (seconds)</label>
|
||||
<input type="number" id="interval_seconds" name="interval_seconds" value="{{ rotation.interval_seconds }}" min="10">
|
||||
</div>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Weather</h1>
|
||||
<p style="font-size:0.85rem;color:#666">Uses Open-Meteo (no API key needed) — just the coordinates for your location.</p>
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="latitude">Latitude</label>
|
||||
<input type="text" id="latitude" name="latitude" value="{{ settings.latitude if settings.latitude is not none else '' }}" placeholder="e.g. 52.52">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="longitude">Longitude</label>
|
||||
<input type="text" id="longitude" name="longitude" value="{{ settings.longitude if settings.longitude is not none else '' }}" placeholder="e.g. 13.40">
|
||||
</div>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user