diff --git a/README.md b/README.md
index 7d7a9c1..3f08c9a 100644
--- a/README.md
+++ b/README.md
@@ -221,9 +221,16 @@ items during the meeting. Those notes autosave per slide and can optionally be
shown on the projector as an overlay. The overlay is plain text: line breaks
are preserved, and the projector toggles it with L.
+`glissade start` also watches your project while it is running. Edit a deck
+JSON file, swap an image under `decks/`, or change `themes.json`, and every
+connected display and remote refreshes to the newest valid version
+automatically. If an edit leaves the deck invalid JSON, Glissade keeps showing
+the last good version and logs the reload error instead.
+
`glissade start --open` opens the deck in your browser once the server is
actually accepting connections — put `open = true` in `glissade.toml` to make
-that a project's default.
+that a project's default. Use `--no-watch` if you want a fixed presentation
+that ignores on-disk changes.
On Windows the first run may raise a firewall prompt: allow private networks
so your phone can reach the remote. `--host 127.0.0.1` avoids it if you only
diff --git a/pyproject.toml b/pyproject.toml
index 403c6d3..a6af6e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "glissade"
-version = "0.12.0"
+version = "0.13.0"
description = "Presentation decks written as JSON, driven from your phone, built into a single self-contained HTML file"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/src/glissade/__init__.py b/src/glissade/__init__.py
index 3bb923f..6c2b36f 100644
--- a/src/glissade/__init__.py
+++ b/src/glissade/__init__.py
@@ -13,7 +13,7 @@
glissade check validate decks before you rely on them
"""
-__version__ = "0.12.0"
+__version__ = "0.13.0"
from .project import ( # noqa: F401
DATA_DIR,
diff --git a/src/glissade/app.py b/src/glissade/app.py
index 2658e31..980d1e1 100644
--- a/src/glissade/app.py
+++ b/src/glissade/app.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
+import contextlib
import json
from dataclasses import dataclass, field
from pathlib import Path
@@ -26,6 +27,11 @@
# and phone browsers dropping it.
HEARTBEAT_SECONDS = 15
+# The live server polls the project for changes so authoring can happen with
+# the deck open. Stdlib polling keeps the install compiler-free everywhere.
+WATCH_INTERVAL_SECONDS = 0.25
+WATCH_DEBOUNCE_SECONDS = 0.15
+
def clamp_scale(value: Any, fallback: float = DEFAULT_SCALE) -> float:
"""Coerce anything to a usable scale factor, rounded to a clean step."""
@@ -145,6 +151,56 @@ def save_live_notes(notes_file: Path, notes: dict[str, dict[str, str]]) -> None:
pass
+def project_snapshot(project: Project) -> dict[str, tuple[int, int] | None]:
+ """A cheap fingerprint of the files a live presentation depends on.
+
+ Deck JSON and deck-local media all live under decks/, so watching that tree
+ catches both rewritten slides and swapped images. The local themes.json is
+ tracked even when absent, so creating or deleting it also reloads clients.
+ """
+ snapshot: dict[str, tuple[int, int] | None] = {}
+
+ if project.decks_dir.is_dir():
+ for path in sorted(project.decks_dir.rglob("*")):
+ if not path.is_file():
+ continue
+ try:
+ stat = path.stat()
+ except OSError:
+ continue
+ snapshot[str(path.resolve())] = (stat.st_mtime_ns, stat.st_size)
+
+ local_themes = project.root / "themes.json"
+ if local_themes.is_file():
+ try:
+ stat = local_themes.stat()
+ snapshot[str(local_themes.resolve())] = (stat.st_mtime_ns, stat.st_size)
+ except OSError:
+ snapshot[str(local_themes.resolve())] = None
+ else:
+ snapshot[str(local_themes.resolve())] = None
+
+ return snapshot
+
+
+def discover_live_decks(project: Project) -> list[dict[str, Any]]:
+ """Read the project's decks, but fail on any broken JSON file.
+
+ `deck_lib.discover()` skips unreadable decks for CLI listing and checking,
+ which is friendly there. Live reload wants the opposite trade: one half-
+ written file from an editor or AI should keep the last good presentation on
+ screen instead of silently swapping to a different subset of decks.
+ """
+ decks = deck_lib.discover(project)
+ expected = {str(path.resolve()) for path in sorted(project.decks_dir.glob("*.json"))}
+ seen = {str(Path(deck["path"]).resolve()) for deck in decks}
+ skipped = sorted(Path(path).name for path in expected - seen)
+ if skipped:
+ names = ", ".join(skipped)
+ raise ValueError(f"can't reload until these deck files parse again: {names}")
+ return decks
+
+
def render_deck(
slides: list[dict[str, Any]],
live: bool,
@@ -196,6 +252,7 @@ class Presentation:
# slides are baked into the page they were served.
rev: int = 0
listeners: set[asyncio.Queue] = field(default_factory=set)
+ reload_error: str = ""
def live_note_for(self, n: int | None = None, deck: str | None = None) -> str:
deck_id = deck if deck is not None else self.deck
@@ -228,6 +285,7 @@ def state(self) -> dict[str, Any]:
"min_scale": MIN_SCALE,
"max_scale": MAX_SCALE,
"all_live_notes": self.live_notes_for_current_deck(),
+ "reload_error": self.reload_error,
}
def goto(self, n: int) -> dict[str, Any]:
@@ -309,7 +367,14 @@ def publish(self) -> dict[str, Any]:
return state
-def create_app(project: Project, deck_name: str | None = None) -> FastAPI:
+def create_app(
+ project: Project,
+ deck_name: str | None = None,
+ *,
+ watch: bool = False,
+ watch_interval: float = WATCH_INTERVAL_SECONDS,
+ watch_debounce: float = WATCH_DEBOUNCE_SECONDS,
+) -> FastAPI:
all_decks = deck_lib.discover(project)
if not all_decks:
raise SystemExit(
@@ -327,7 +392,20 @@ def create_app(project: Project, deck_name: str | None = None) -> FastAPI:
current = deck_lib.resolve(deck_name or settings["deck"], all_decks)
- app = FastAPI(title="Glissade", docs_url=None, redoc_url=None)
+ @contextlib.asynccontextmanager
+ async def lifespan(app: FastAPI):
+ if watch:
+ app.state.watch_task = asyncio.create_task(watch_project())
+ try:
+ yield
+ finally:
+ task = getattr(app.state, "watch_task", None)
+ if task is not None:
+ task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await task
+
+ app = FastAPI(title="Glissade", docs_url=None, redoc_url=None, lifespan=lifespan)
# Slides are prepared (media inlined) once per deck and cached, since
# base64-encoding images on every request would be wasteful.
@@ -361,6 +439,72 @@ def deck_by_id(deck_id: str | None) -> dict[str, Any]:
app.state.show = show
app.state.decks = all_decks
app.state.project = project
+ app.state.themes = themes
+
+ def reload_project_state() -> dict[str, Any]:
+ """Reload decks, themes and prepared assets after an on-disk change.
+
+ Reload is atomic from the clients' point of view: either the new files
+ parse and everyone is told to refresh, or the last good presentation
+ keeps running and only an error message changes.
+ """
+ nonlocal all_decks, current, themes, theme_ids, deck_ids
+
+ new_decks = discover_live_decks(project)
+ if not new_decks:
+ raise ValueError(f"no readable decks found in {project.decks_dir}")
+ new_themes = load_themes(project)
+ new_theme_ids = [t["id"] for t in new_themes]
+ new_deck_ids = [d["id"] for d in new_decks]
+ selected = show.deck if show.deck in new_deck_ids else None
+ new_current = deck_lib.resolve(selected, new_decks)
+ if new_current is None:
+ raise ValueError(f"no readable decks found in {project.decks_dir}")
+
+ all_decks = new_decks
+ current = new_current
+ themes = new_themes
+ theme_ids = new_theme_ids
+ deck_ids = new_deck_ids
+ cache.clear()
+
+ show.deck_ids = new_deck_ids
+ show.theme_ids = new_theme_ids
+ show.deck = new_current["id"]
+ if show.theme not in new_theme_ids:
+ show.theme = new_theme_ids[0] if new_theme_ids else "paper"
+ show.total = len(slides_for(new_current))
+ show.n = max(1, min(show.total, show.n))
+ show.reload_error = ""
+ show.rev += 1
+ show._save()
+ app.state.decks = all_decks
+ app.state.themes = themes
+ return show.publish()
+
+ async def watch_project() -> None:
+ snapshot = project_snapshot(project)
+ while True:
+ await asyncio.sleep(watch_interval)
+ latest = project_snapshot(project)
+ if latest == snapshot:
+ continue
+ await asyncio.sleep(watch_debounce)
+ latest = project_snapshot(project)
+ if latest == snapshot:
+ continue
+ snapshot = latest
+ try:
+ state = reload_project_state()
+ deck = deck_by_id(show.deck)
+ print(f" ↻ reloaded {deck['id']} ({len(deck['slides'])} slides)")
+ except Exception as exc:
+ show.reload_error = str(exc)
+ state = show.publish()
+ print(f" ! reload failed: {exc}")
+ app.state.last_reload_state = state
+
+ app.state.reload_project = reload_project_state
def switch(deck_id: str) -> dict[str, Any]:
"""Change the running deck. Bumps `rev` so open displays reload —
@@ -385,7 +529,12 @@ async def display() -> HTMLResponse:
"""The projected deck."""
deck = deck_by_id(show.deck)
return no_store(
- render_deck(slides_for(deck), live=True, themes=themes, title=deck["title"])
+ render_deck(
+ slides_for(deck),
+ live=True,
+ themes=app.state.themes,
+ title=deck["title"],
+ )
)
@app.get("/control", response_class=HTMLResponse)
@@ -401,7 +550,7 @@ async def api_slides() -> JSONResponse:
@app.get("/api/themes")
async def api_themes() -> JSONResponse:
- return JSONResponse(themes, headers={"Cache-Control": "no-store"})
+ return JSONResponse(app.state.themes, headers={"Cache-Control": "no-store"})
@app.get("/api/decks")
async def api_decks() -> JSONResponse:
diff --git a/src/glissade/cli.py b/src/glissade/cli.py
index 559ad45..34eda78 100644
--- a/src/glissade/cli.py
+++ b/src/glissade/cli.py
@@ -208,7 +208,7 @@ def cmd_start(args) -> int:
host = args.host or str(defaults.get("host", "0.0.0.0"))
deck = args.deck or defaults.get("deck")
- app = create_app(project, deck)
+ app = create_app(project, deck, watch=not args.no_watch and not args._demo)
_warn_if_deck_needs_newer(app.state.decks)
show = app.state.show
current = next((d for d in app.state.decks if d["id"] == show.deck), None)
@@ -607,6 +607,8 @@ def common(sp):
sp.add_argument("--port", type=int, default=None, help="port (default 8000)")
sp.add_argument("--open", action="store_true",
help="open the deck in your browser once the server is up")
+ sp.add_argument("--no-watch", action="store_true",
+ help="do not auto-reload when deck files change")
sp.set_defaults(func=cmd_start)
sp = common(sub.add_parser("build", help="write standalone HTML"))
@@ -649,6 +651,7 @@ def common(sp):
sp.add_argument("--host", default=None)
sp.add_argument("--port", type=int, default=None)
sp.add_argument("--open", action="store_true")
+ sp.add_argument("--no-watch", action="store_true")
sp.set_defaults(func=cmd_start, _demo=True, dir=None)
return p
diff --git a/src/glissade/data/demo/decks/gallery.json b/src/glissade/data/demo/decks/gallery.json
index 5dc68f3..5b99d66 100644
--- a/src/glissade/data/demo/decks/gallery.json
+++ b/src/glissade/data/demo/decks/gallery.json
@@ -227,7 +227,7 @@
"eyebrow": "Needs live internet",
"heading": "An external embed",
"body": "If it can't load within six seconds, a QR code appears instead of a blank frame. Press R to retry.",
- "notes": "External embed. \"media\": {\"src\": \"https://www.youtube.com/embed/…\"}
Use the /embed/ URL form, not watch?v=.pixi run build lists every slide like this one."
+ "notes": "External embed. \"media\": {\"src\": \"https://www.youtube.com/embed/…\"}
Use the /embed/ URL form, not watch?v=.glissade build lists every slide like this one."
},
{
"n": 16,
diff --git a/src/glissade/data/demo/decks/tour.json b/src/glissade/data/demo/decks/tour.json
index f806907..214e5f9 100644
--- a/src/glissade/data/demo/decks/tour.json
+++ b/src/glissade/data/demo/decks/tour.json
@@ -54,11 +54,11 @@
"heading": "Two commands",
"left": {
"subheading": "Present",
- "body": "
pixi run start
Serves the deck to the projector and a remote to your phone. Prints both addresses and a QR code.
" + "body": "glissade start
Serves the deck to the projector and a remote to your phone. Prints both addresses and a QR code, then watches the project for deck and theme changes.
" }, "right": { "subheading": "Build", - "body": "pixi run build
Writes every deck to build/ as a standalone file. No server needed to open it.
" + "body": "glissade build
Writes every deck to build/ as a standalone file. No server needed to open it.
" }, "notes": "layout: \"two-content\" — two independent regions side by side.Reloaded.
", + "notes": "Wrap up.", + } + ) + deck_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + app.state.reload_project() + + after = client.get("/api/state").json() + assert after["rev"] == 1 + assert after["total"] == 3 + assert after["reload_error"] == "" + + slides = client.get("/api/slides").json() + assert slides[0]["heading"] == "Fresh heading from disk" + assert slides[-1]["heading"] == "Thanks" + + +def test_reload_project_state_keeps_last_good_deck_on_invalid_json(tmp_path, sample_deck_file): + project = find_project(tmp_path) + app = create_app(project) + client = TestClient(app) + + sample_deck_file.write_text("{ not valid json", encoding="utf-8") + + before = client.get("/api/slides").json() + state = client.get("/api/state").json() + assert state["rev"] == 0 + + try: + app.state.reload_project() + except ValueError as exc: + app.state.show.reload_error = str(exc) + app.state.show.publish() + + after = client.get("/api/slides").json() + state = client.get("/api/state").json() + assert after == before + assert state["rev"] == 0 + assert "can't reload until these deck files parse again" in state["reload_error"] diff --git a/tests/test_cli.py b/tests/test_cli.py index 9b2c0d7..a69dc42 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,4 @@ -from glissade.cli import main +from glissade.cli import build_parser, main def test_cli_init_and_decks(tmp_path, monkeypatch): @@ -42,3 +42,16 @@ def test_cli_check_command(tmp_path, monkeypatch): # Run glissade check on scaffolded project ret = main(["check"]) assert ret == 0 + + +def test_cli_start_watch_defaults(): + parser = build_parser() + + args = parser.parse_args(["start"]) + assert args.no_watch is False + + args = parser.parse_args(["start", "--no-watch"]) + assert args.no_watch is True + + args = parser.parse_args(["demo"]) + assert args.no_watch is False