Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>L</kbd>.

`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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/glissade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
157 changes: 153 additions & 4 deletions src/glissade/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import contextlib
import json
from dataclasses import dataclass, field
from pathlib import Path
Expand All @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand Down Expand Up @@ -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 —
Expand All @@ -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)
Expand All @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion src/glissade/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/glissade/data/demo/decks/gallery.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<b>External embed.</b> <code>\"media\": {\"src\": \"https://www.youtube.com/embed/…\"}</code><br><br>Use the <b>/embed/</b> URL form, not <code>watch?v=</code>.<br><br><code>pixi run build</code> lists every slide like this one."
"notes": "<b>External embed.</b> <code>\"media\": {\"src\": \"https://www.youtube.com/embed/…\"}</code><br><br>Use the <b>/embed/</b> URL form, not <code>watch?v=</code>.<br><br><code>glissade build</code> lists every slide like this one."
},
{
"n": 16,
Expand Down
6 changes: 3 additions & 3 deletions src/glissade/data/demo/decks/tour.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@
"heading": "Two commands",
"left": {
"subheading": "Present",
"body": "<p><b>pixi run start</b></p><p>Serves the deck to the projector and a remote to your phone. Prints both addresses and a QR code.</p>"
"body": "<p><b>glissade start</b></p><p>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.</p>"
},
"right": {
"subheading": "Build",
"body": "<p><b>pixi run build</b></p><p>Writes every deck to <b>build/</b> as a standalone file. No server needed to open it.</p>"
"body": "<p><b>glissade build</b></p><p>Writes every deck to <b>build/</b> as a standalone file. No server needed to open it.</p>"
},
"notes": "<b>layout: \"two-content\"</b> — two independent regions side by side.<br><br>Start is what you use in the room. Build is what you do the night before, so there's a file on the laptop that works even if nothing else does."
},
Expand Down Expand Up @@ -268,7 +268,7 @@
"eyebrow": "The one thing that needs the network",
"heading": "External embeds",
"body": "A YouTube URL can't be embedded into a file. If it can't load, this slide shows a QR code instead of a blank frame — press R to retry.",
"notes": "<b>layout: \"media-full\"</b> — media fills the slide, text sits in a gradient panel.<br><br>Use the <b>/embed/</b> URL form, not the watch link.<br><br><b>pixi run build</b> lists every slide like this one, so you know your network dependencies before you're standing in front of a room.<br><br>Embeds mount only while their slide is showing — otherwise a video would keep playing, audibly, after you moved on."
"notes": "<b>layout: \"media-full\"</b> — media fills the slide, text sits in a gradient panel.<br><br>Use the <b>/embed/</b> URL form, not the watch link.<br><br><b>glissade build</b> lists every slide like this one, so you know your network dependencies before you're standing in front of a room.<br><br>Embeds mount only while their slide is showing — otherwise a video would keep playing, audibly, after you moved on."
},
{
"n": 18,
Expand Down
2 changes: 1 addition & 1 deletion src/glissade/data/scaffold/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ fastest way to know a deck is sound. Treat a non-zero exit as work unfinished.

```bash
glissade check # validate every deck
glissade start # present, with a phone remote
glissade start # present, with a phone remote and live deck reloads
glissade build # write standalone HTML to build/
```

Expand Down
2 changes: 1 addition & 1 deletion src/glissade/data/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://glissade.dev/schema/v1.json",
"x-glissade-schema": 1,
"x-glissade-version": "0.12.0",
"x-glissade-version": "0.13.0",
"title": "Glissade deck",
"description": "A presentation deck. Either an object with metadata and a slides array, or a bare array of slides.",
"oneOf": [
Expand Down
2 changes: 1 addition & 1 deletion src/glissade/templates/control.html
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ <h2 id="nexttitle"></h2>
if(openPanelName !== 'settings') openPanel('settings');
const el = $('sizewarn');
el.textContent = r.status === 404
? 'This server was started before text sizing existed — restart it (pixi run start).'
? 'This server was started before text sizing existed — restart it (glissade start).'
: 'Could not reach the server.';
el.classList.add('on');
}
Expand Down
Loading
Loading