From 1aad68628afb478cf20827e8d2087c43225cbc41 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 14:20:12 -0400 Subject: [PATCH 01/23] Add ShowXpress lighting control --- README.md | 3 +- app/main.py | 48 ++++++++++++++ app/models.py | 3 +- app/services/thelightingcontroller.py | 96 +++++++++++++++++++++++++++ app/static/admin.html | 1 + app/static/admin.js | 6 +- app/static/common.js | 3 +- app/static/display.js | 7 ++ app/static/editor.js | 4 +- app/store.py | 8 ++- 10 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 app/services/thelightingcontroller.py diff --git a/README.md b/README.md index 7b50cc0..e470a58 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ See ChurchBoard in action and follow the setup walkthrough in the **[ChurchBoard - Direct Open Sound Meter monitoring with selectable weighting/response and downloadable service graphs and per-item averages - Restream broadcast, viewer, and destination-status monitoring through OAuth - OBS Studio streaming/recording state, connection health, output statistics, dropped-frame warnings, and an optional preview image +- Native lighting control for Chauvet ShowXpress, Showtec QuickDMX, Sweetlight Controller, and other TheLightingController-based apps, with their exposed Live buttons shown on a dashboard - Planning Center Services LIVE control buttons - A WYSIWYG dashboard editor with independent layouts and color-matched liquid-glass widgets for each destination @@ -87,7 +88,7 @@ Open `http://127.0.0.1:8040/admin`, turn off demonstration data, and configure P ![ChurchBoard integrations setup](docs/screenshots/setup.jpg) -Start with [Configuration](docs/CONFIGURATION.md), then follow the detailed [Planning Center setup](docs/PLANNING_CENTER.md), [ProPresenter setup](docs/PROPRESENTER.md), [Open Sound Meter setup](docs/OPEN_SOUND_METER.md), and [Restream setup](docs/RESTREAM.md) guides. They cover secure credentials, permissions, photos, leaders, linked service playlists, the Network API, Services LIVE automation, dashboards, microphone mapping, level reporting, livestream monitoring, and troubleshooting. +Start with [Configuration](docs/CONFIGURATION.md), then follow the detailed [Planning Center setup](docs/PLANNING_CENTER.md), [ProPresenter setup](docs/PROPRESENTER.md), [Open Sound Meter setup](docs/OPEN_SOUND_METER.md), and [Restream setup](docs/RESTREAM.md) guides. To control ShowXpress-compatible lighting software, enable its External App and External Control settings, configure the matching address, port (normally 7348), and password in Setup, then add a Lighting controls widget to a dashboard. Only pages marked visible to external applications appear in ChurchBoard. ## Dashboard editing diff --git a/app/main.py b/app/main.py index 0f34479..b1ab9ff 100644 --- a/app/main.py +++ b/app/main.py @@ -25,6 +25,7 @@ from app.services.planning_center import PlanningCenterClient from app.services.propresenter import ProPresenterClient from app.services.restream import RestreamClient +from app.services.thelightingcontroller import TheLightingControllerClient from app.store import ConfigStore from app.update import download_update, update_status from app.version import __version__ @@ -67,6 +68,13 @@ class ProPresenterNavigationRequest(BaseModel): widget_id: str | None = None +class LightingButtonTrigger(BaseModel): + name: str + mode: str = "toggle" + dashboard_slug: str | None = None + widget_id: str | None = None + + @asynccontextmanager async def lifespan(app: FastAPI): config = load_config() @@ -198,6 +206,8 @@ async def update_settings(payload: SettingsUpdate, request: Request) -> dict: settings.setdefault("restream", {})[secret_name] = existing_restream.get(secret_name, "") if not settings.get("obs", {}).get("password"): settings.setdefault("obs", {})["password"] = data["settings"].get("obs", {}).get("password", "") + if not settings.get("lighting", {}).get("password"): + settings.setdefault("lighting", {})["password"] = data["settings"].get("lighting", {}).get("password", "") data["settings"] = settings store.save(data) await request.app.state.runtime.refresh(force=True) @@ -386,6 +396,44 @@ async def test_restream(request: Request) -> dict: await client.close() +@app.get("/api/integrations/lighting/buttons") +async def lighting_buttons(request: Request) -> dict: + client = TheLightingControllerClient(store_from(request).load()["settings"].get("lighting", {})) + if not client.configured: + raise HTTPException(400, "Enable lighting control and save its computer address first") + try: + buttons = await client.buttons() + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except Exception as exc: + raise HTTPException(502, f"Could not read lighting controls: {exc}") from exc + return {"connected": True, "items": buttons, "count": len(buttons)} + + +def require_lighting_widget_control(request: Request, dashboard_slug: str | None, widget_id: str | None) -> dict: + data = store_from(request).load() + dashboard = next((item for item in data.get("dashboards", []) if str(item.get("slug") or "") == str(dashboard_slug or "")), None) + widget = next((item for item in (dashboard or {}).get("widgets", []) if str(item.get("id") or "") == str(widget_id or "")), None) + if not widget or widget.get("type") != "lighting" or widget.get("settings", {}).get("allow_remote_trigger") is False: + raise HTTPException(403, "Lighting triggering is disabled in this widget's settings") + return data["settings"].get("lighting", {}) + + +@app.post("/api/integrations/lighting/button") +async def lighting_trigger_button(payload: LightingButtonTrigger, request: Request) -> dict: + settings = require_lighting_widget_control(request, payload.dashboard_slug, payload.widget_id) + client = TheLightingControllerClient(settings) + if not client.configured: + raise HTTPException(400, "Lighting control is not connected") + try: + await client.trigger_button(payload.name, payload.mode) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except Exception as exc: + raise HTTPException(502, f"Could not trigger lighting button: {exc}") from exc + return {"ok": True, "name": payload.name, "mode": payload.mode} + + RESTREAM_CALLBACK_PATH = "/api/integrations/restream/callback" diff --git a/app/models.py b/app/models.py index b4d92bd..69650e2 100644 --- a/app/models.py +++ b/app/models.py @@ -8,7 +8,7 @@ class Widget(BaseModel): id: str = Field(min_length=1, max_length=80) - type: Literal["clock", "service", "timing", "assignments", "mics", "slides", "playlist", "notes", "order", "person", "people", "spl", "controls", "text", "restream", "obs", "propresenter_timers"] + type: Literal["clock", "service", "timing", "assignments", "mics", "slides", "playlist", "notes", "order", "person", "people", "spl", "controls", "lighting", "text", "restream", "obs", "propresenter_timers"] x: int = Field(ge=0, le=23) y: int = Field(ge=0, le=100) w: int = Field(ge=1, le=24) @@ -54,5 +54,6 @@ class SettingsUpdate(BaseModel): open_sound_meter: dict[str, Any] = Field(default_factory=dict) restream: dict[str, Any] = Field(default_factory=dict) obs: dict[str, Any] = Field(default_factory=dict) + lighting: dict[str, Any] = Field(default_factory=dict) position_mic_map: dict[str, str] = Field(default_factory=dict) manual_plan: dict[str, str] | None = None diff --git a/app/services/thelightingcontroller.py b/app/services/thelightingcontroller.py new file mode 100644 index 0000000..882db03 --- /dev/null +++ b/app/services/thelightingcontroller.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import asyncio +from typing import Any +from xml.etree import ElementTree + + +class TheLightingControllerClient: + """Client for TLC's External Application protocol (also used by ShowXpress).""" + + def __init__(self, settings: dict[str, Any]): + self.settings = settings + + @property + def configured(self) -> bool: + return bool(self.settings.get("enabled") and str(self.settings.get("host") or "").strip()) + + async def buttons(self) -> list[dict[str, Any]]: + reader, writer = await self._connect() + try: + await self._send(writer, "BUTTON_LIST") + while True: + line = await asyncio.wait_for(reader.readline(), timeout=3) + if not line: + raise ConnectionError("The lighting controller closed the connection") + text = line.decode("utf-8", "replace").rstrip("\r\n") + if text.startswith("ERROR|"): + raise ValueError(text.split("|", 1)[1] or "The lighting controller rejected the request") + if text.startswith("BUTTON_LIST|"): + return self._parse_buttons(text.split("|", 1)[1]) + finally: + writer.close() + await writer.wait_closed() + + async def trigger_button(self, name: str, mode: str = "toggle") -> None: + if not name or any(character in name for character in "|\r\n"): + raise ValueError("Invalid lighting button name") + if mode not in {"press", "release", "toggle"}: + raise ValueError("Lighting button mode must be press, release, or toggle") + # Query first so ChurchBoard never becomes an arbitrary TCP command proxy. + buttons = await self.buttons() + button = next((item for item in buttons if item["name"] == name), None) + if button is None: + raise ValueError("That lighting button is no longer exposed by the controller") + command = "BUTTON_PRESS" if mode == "press" or (mode == "toggle" and not button["pressed"]) else "BUTTON_RELEASE" + reader, writer = await self._connect() + del reader + try: + await self._send(writer, command, name) + finally: + writer.close() + await writer.wait_closed() + + async def _connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + host = str(self.settings.get("host") or "").strip() + port = int(self.settings.get("port") or 7348) + reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=3) + await self._send(writer, "HELLO", "churchboard", str(self.settings.get("password") or "")) + while True: + line = await asyncio.wait_for(reader.readline(), timeout=3) + if not line: + writer.close() + await writer.wait_closed() + raise ConnectionError("The lighting controller closed the connection during sign-in") + text = line.decode("utf-8", "replace").rstrip("\r\n") + if text == "HELLO": + return reader, writer + if text.startswith("ERROR|"): + writer.close() + await writer.wait_closed() + raise ValueError(text.split("|", 1)[1] or "The lighting controller rejected the password") + + @staticmethod + async def _send(writer: asyncio.StreamWriter, *parts: str) -> None: + writer.write(("|".join(parts) + "\r\n").encode("ascii")) + await writer.drain() + + @staticmethod + def _parse_buttons(payload: str) -> list[dict[str, Any]]: + try: + root = ElementTree.fromstring(payload) + except ElementTree.ParseError as exc: + raise ValueError(f"The lighting controller returned invalid button data: {exc}") from exc + buttons: list[dict[str, Any]] = [] + for page in root.findall("page"): + page_name = page.get("name") or "Lighting" + for element in page.findall("button"): + name = (element.text or "").strip() + if not name: + continue + buttons.append({ + "name": name, "page": page_name, "column": int(element.get("column") or 0), + "line": int(element.get("line") or 0), "color": element.get("color") or "#4c6b8a", + "pressed": element.get("pressed") == "1", "flash": element.get("flash") == "1", + }) + return sorted(buttons, key=lambda item: (item["page"].casefold(), item["line"], item["column"], item["name"].casefold())) diff --git a/app/static/admin.html b/app/static/admin.html index 62b5f6e..a967752 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -8,6 +8,7 @@
ProPresenter

Slide triggering is enabled separately on each ProPresenter Playlist widget in the board editor.

Services LIVE automation

Planning Center-synced ProPresenter items match by their playlist item and position first, including non-song items such as Message. Strong title matches can also select non-song items when no synced link is available.

Restream

In your Restream app, add http://127.0.0.1:8040/api/integrations/restream/callback as a Redirect URI and select the scopes you require. ChurchBoard keeps the secret and OAuth tokens only on this computer.

OBS Studio

In OBS, open Tools → WebSocket Server Settings, enable the server, and enter the same port and password here. A preview image URL is optional; OBS status monitoring works without it.

+
Lighting control

Works with Chauvet ShowXpress, Showtec QuickDMX, Sweetlight Controller, and other TheLightingController apps. In the app, enable Preferences → Network – External App and Live Settings → Network – External Control; the usual port is 7348. Mark each desired page visible in the external application.

Wireless microphones

Add each receiver channel, select its receiver family, and assign its scheduled Planning Center position. Shure receivers use TCP port 2202; SLX-D requires Controller Access to be allowed in Advanced Settings. Sennheiser uses legacy SSC over UDP port 45.

Open Sound Meter

In Open Sound Meter, enable the Wi‑Fi icon’s Remote API Server. ChurchBoard listens for its UDP multicast level packets at 239.255.42.42:49007. The two computers must be on the same multicast-enabled network segment.

Waiting for OSM multicast data.
diff --git a/app/static/admin.js b/app/static/admin.js index ea8a9d3..d037376 100644 --- a/app/static/admin.js +++ b/app/static/admin.js @@ -62,7 +62,7 @@ async function loadPositionCatalog(){ async function loadSettings(){ settings=await api("/api/settings"); - const pc=settings.planning_center||{},pp=settings.propresenter||{},sh=settings.shure||{},se=settings.sennheiser||{},osm=settings.open_sound_meter||{},restream=settings.restream||{},obs=settings.obs||{},live=pc.live_from_propresenter||{}; + const pc=settings.planning_center||{},pp=settings.propresenter||{},sh=settings.shure||{},se=settings.sennheiser||{},osm=settings.open_sound_meter||{},restream=settings.restream||{},obs=settings.obs||{},lighting=settings.lighting||{},live=pc.live_from_propresenter||{}; serviceTypeRows=pc.service_types||[]; try{const runtime=await api("/api/runtime"),service=runtime.service||{},id=String(service.service_type_id||""),liveStatus=runtime.planning_center_live||{};if(id&&service.service_type_name&&!serviceTypeRows.some(item=>String(item.id)===id))serviceTypeRows.push({id,name:service.service_type_name});document.querySelector("#pp-live-status").textContent=liveStatus.message||""}catch(error){} sf.organization_name.value=settings.organization_name||"";await loadTimezoneOptions(settings.timezone||"America/New_York");sf.demo_mode.checked=!!settings.demo_mode; @@ -70,6 +70,7 @@ async function loadSettings(){ sf.pp_enabled.checked=!!pp.enabled;sf.pp_host.value=pp.host||"127.0.0.1";sf.pp_port.value=pp.port||50001;sf.shure_enabled.checked=!!sh.enabled;sf.osm_enabled.checked=!!osm.enabled;sf.osm_reports_enabled.checked=osm.reports_enabled!==false;sf.osm_report_weighting.value=osm.report_weighting||"A";sf.osm_report_response.value=osm.report_response||"Fast";try{const runtime=await api("/api/runtime"),latest=runtime.osm||{},sources=latest.sources||[];sf.osm_source_id.innerHTML=''+sources.map(source=>``).join("");sf.osm_source_id.value=osm.source_id||"";document.querySelector("#osm-status").textContent=latest.connected?`Connected to ${latest.source_name||"OSM source"} · A Fast ${Number(latest.a_fast||latest.laeq).toFixed(1)} dB`:"Waiting for OSM multicast data."}catch(error){} sf.restream_enabled.checked=!!restream.enabled;sf.restream_client_id.value=restream.client_id||"";document.querySelector("#restream-status").textContent=restream.access_token_configured?"Restream account connected":restream.client_secret_configured?"Client Secret saved; connect the account":""; sf.obs_enabled.checked=!!obs.enabled;sf.obs_host.value=obs.host||"127.0.0.1";sf.obs_port.value=obs.port||4455;sf.obs_dropped_frames_threshold.value=Number(obs.dropped_frames_threshold??2);sf.obs_preview_url.value=obs.preview_url||""; + sf.lighting_enabled.checked=!!lighting.enabled;sf.lighting_host.value=lighting.host||"127.0.0.1";sf.lighting_port.value=lighting.port||7348;document.querySelector("#lighting-status").textContent=lighting.password_configured?"Password saved; load buttons to test the connection":""; sf.pp_live_enabled.checked=!!live.enabled;sf.pp_live_take_control.checked=live.auto_take_control!==false;sf.pp_live_songs_only.checked=live.songs_only!==false;sf.pp_live_allow_previous.checked=!!live.allow_previous;sf.pp_live_match_mode.value=live.match_mode||"exact";sf.pp_live_stable_seconds.value=Number(live.stable_seconds??2); document.querySelector("#pc-status").textContent=pc.secret_configured?"Token saved; connection not yet tested":""; sf.sennheiser_enabled.checked=!!se.enabled;renderServiceTypes();hydrateMics(sh,se);await loadPositionCatalog(); @@ -84,7 +85,7 @@ function settingsPayload(){ return {...settings,organization_name:sf.organization_name.value,timezone:sf.timezone.value,demo_mode:sf.demo_mode.checked, planning_center:{...pcBase,enabled:sf.pc_enabled.checked,application_id:sf.pc_application_id.value,secret:sf.pc_secret.value,service_type_ids:serviceTypeIds,service_types:serviceTypeRows,open_days_before:Number(sf.pc_days.value),open_hours_before:Number(sf.pc_hours.value),close_hours_after:Number(sf.pc_close.value),live_from_propresenter:{...(pcBase.live_from_propresenter||{}),enabled:sf.pp_live_enabled.checked,auto_take_control:sf.pp_live_take_control.checked,songs_only:sf.pp_live_songs_only.checked,allow_previous:sf.pp_live_allow_previous.checked,match_mode:sf.pp_live_match_mode.value,stable_seconds:Math.max(0,Number(sf.pp_live_stable_seconds.value)||0)}}, propresenter:{...(settings.propresenter||{}),enabled:sf.pp_enabled.checked,host:sf.pp_host.value,port:Number(sf.pp_port.value)}, - restream:{...(settings.restream||{}),enabled:sf.restream_enabled.checked,client_id:sf.restream_client_id.value.trim(),client_secret:sf.restream_client_secret.value},obs:{...(settings.obs||{}),enabled:sf.obs_enabled.checked,host:sf.obs_host.value.trim(),port:Number(sf.obs_port.value)||4455,password:sf.obs_password.value||settings.obs?.password||"",dropped_frames_threshold:Math.max(0,Number(sf.obs_dropped_frames_threshold.value)||0),preview_url:sf.obs_preview_url.value.trim()}, + restream:{...(settings.restream||{}),enabled:sf.restream_enabled.checked,client_id:sf.restream_client_id.value.trim(),client_secret:sf.restream_client_secret.value},obs:{...(settings.obs||{}),enabled:sf.obs_enabled.checked,host:sf.obs_host.value.trim(),port:Number(sf.obs_port.value)||4455,password:sf.obs_password.value||settings.obs?.password||"",dropped_frames_threshold:Math.max(0,Number(sf.obs_dropped_frames_threshold.value)||0),preview_url:sf.obs_preview_url.value.trim()},lighting:{...(settings.lighting||{}),enabled:sf.lighting_enabled.checked,host:sf.lighting_host.value.trim(),port:Number(sf.lighting_port.value)||7348,password:sf.lighting_password.value||settings.lighting?.password||""}, shure:{...(settings.shure||{}),enabled:sf.shure_enabled.checked,receivers:[],mics:micRows.filter(mic=>["shure","shure-slxd"].includes(mic.manufacturer||"shure")).map(({manufacturer,...mic})=>({...mic,name:String(mic.name).trim(),host:String(mic.host).trim(),port:Number(mic.port)||2202,channel:Number(mic.channel)||1,model:manufacturer==="shure-slxd"?"slxd":"qlx-ulx"}))},sennheiser:{...(settings.sennheiser||{}),enabled:sf.sennheiser_enabled.checked,receivers:[],mics:micRows.filter(mic=>mic.manufacturer==="sennheiser").map(({manufacturer,...mic})=>({...mic,name:String(mic.name).trim(),host:String(mic.host).trim(),port:Number(mic.port)||45,channel:Number(mic.channel)||1}))},open_sound_meter:{...(settings.open_sound_meter||{}),enabled:sf.osm_enabled.checked,reports_enabled:sf.osm_reports_enabled.checked,report_weighting:sf.osm_report_weighting.value,report_response:sf.osm_report_response.value,source_id:sf.osm_source_id.value},position_mic_map:micMap}; } @@ -107,6 +108,7 @@ document.querySelector("#pc-test").addEventListener("click",async()=>{ }); document.querySelector("#restream-connect").addEventListener("click",async()=>{const status=document.querySelector("#restream-status");status.textContent="Saving…";try{await saveSettings(false);location.href="/api/integrations/restream/connect"}catch(error){status.textContent=error.message}}); document.querySelector("#restream-test").addEventListener("click",async()=>{const status=document.querySelector("#restream-status");status.textContent="Testing…";try{await saveSettings(false);const result=await api("/api/integrations/restream/test",{method:"POST"});status.textContent=`Connected · ${result.count} destination${result.count===1?"":"s"}`;sf.restream_client_secret.value=""}catch(error){status.textContent=error.message}}); +document.querySelector("#lighting-test").addEventListener("click",async()=>{const status=document.querySelector("#lighting-status");status.textContent="Loading controls…";try{await saveSettings(false);const result=await api("/api/integrations/lighting/buttons");status.textContent=`Connected · ${result.count} lighting button${result.count===1?"":"s"} exposed`;sf.lighting_password.value=""}catch(error){status.textContent=error.message}}); document.querySelector("#add-mic").addEventListener("click",()=>{micRows.push({id:micId(),name:`Mic ${micRows.length+1}`,manufacturer:"shure",host:"",port:2202,channel:1,position_key:""});sf.shure_enabled.checked=true;renderMicManager();document.querySelector("#settings-status").textContent="Unsaved microphone changes"}); document.querySelector("#mic-manager").addEventListener("input",event=>{const field=event.target.dataset.micField,row=event.target.closest("[data-mic-id]");if(!field||!row)return;const mic=micRows.find(item=>item.id===row.dataset.micId);if(!mic)return;mic[field]=field==="channel"?Number(event.target.value)||1:event.target.value;if(field==="manufacturer")mic.port=mic[field]==="sennheiser"?45:2202;document.querySelector("#settings-status").textContent="Unsaved microphone changes"}); diff --git a/app/static/common.js b/app/static/common.js index b461be5..f7c958e 100644 --- a/app/static/common.js +++ b/app/static/common.js @@ -145,7 +145,7 @@ const enhanceDynamicContent = (root=document) => { }));requestAnimationFrame(()=>resizeDashboardContent(root)); if(!root._churchBoardResizeObserver&&window.ResizeObserver){root._churchBoardResizeObserver=new ResizeObserver(()=>resizeDashboardContent(root));root._churchBoardResizeObserver.observe(root)} }; -const widgetNames = {clock:"Clock",service:"Service",timing:"Timers",assignments:"Scheduled Positions & Mics",mics:"Scheduled Positions & Mics",slides:"ProPresenter slides",playlist:"ProPresenter playlist",notes:"Slide notes",order:"Order of service",people:"Team members",spl:"Open Sound Meter",controls:"Service controls",person:"Scheduled person",restream:"Restream livestream",obs:"OBS live monitor",propresenter_timers:"ProPresenter timers",text:"Custom text"}; +const widgetNames = {clock:"Clock",service:"Service",timing:"Timers",assignments:"Scheduled Positions & Mics",mics:"Scheduled Positions & Mics",slides:"ProPresenter slides",playlist:"ProPresenter playlist",notes:"Slide notes",order:"Order of service",people:"Team members",spl:"Open Sound Meter",controls:"Service controls",lighting:"Lighting controls",person:"Scheduled person",restream:"Restream livestream",obs:"OBS live monitor",propresenter_timers:"ProPresenter timers",text:"Custom text"}; const widgetMarkup = (widget, state) => { const settings=widget.settings||{}, service=state.service||{}, timing=state.timing||{}, pp=state.propresenter||{}; let content=""; @@ -162,6 +162,7 @@ const widgetMarkup = (widget, state) => { if(widget.type==="people") { const people=filteredPeople(settings,state);content=people.length?`
${people.map(person=>`
${person.photo?``:`${initials(person.name)}`}
${escapeHtml(person.name||"Unassigned")}${escapeHtml([person.position,person.team_name].filter(Boolean).join(" · "))}
`).join("")}
`:`
No scheduled people match these filters
`; } if(widget.type==="spl") { const green=Number(settings.green_max??75),orange=Number(settings.orange_max??85),weighting=["A","B","C","Z"].includes(settings.weighting)?settings.weighting:"A",response=settings.response==="Slow"?"Slow":"Fast",metricKey=`${weighting.toLowerCase()}_${response.toLowerCase()}`,metricLabel=`${weighting}-weighted ${response}`,osm=state.osm||{},value=Number(osm[metricKey]),reports=osm.reports_enabled!==false&&settings.reports_enabled!==false&&service.id?``:"";content=`
${Number.isFinite(value)?value.toFixed(1):"--"}dB
${metricLabel} · Green ≤ ${green}Orange ≤ ${orange}Red > ${orange}
${osm.connected?"Open Sound Meter connected":"Waiting for Open Sound Meter"}
${reports}
`; } if(widget.type==="controls") { const control=state.service_control||{},pcLive=state.planning_center_live||{},item=timing.current_item||{},isControlling=pcLive.enabled?!!pcLive.has_control:!!control.active,controlLabel=pcLive.enabled?"ProPresenter → Services LIVE":control.active?"Local control":"Following schedule",statusMessage=pcLive.enabled?pcLive.message||"":"";content=`
${escapeHtml(controlLabel)}${escapeHtml(item.title||"No current item")}
${escapeHtml(statusMessage)}
`; } + if(widget.type==="lighting") content=`
Loading exposed lighting controls…
`; if(widget.type==="person") { const person=(state.people||[]).find(p=>p.position===settings.position); content=person?`
${person.photo?``:initials(person.name)}
${escapeHtml(person.name)}
${escapeHtml(person.position)}
`:`
Choose a Planning Center position in the editor
`; } if(widget.type==="text") content=`
${escapeHtml(settings.text||"Custom text")}
`; if(widget.type==="restream") { const stream=state.restream||{},destinations=stream.destinations||[],status=String(stream.status||"offline").replace("-"," "),duration=formatDuration(stream.duration_seconds||0).replace(/^−/,"");content=stream.connected?`
${escapeHtml(status)}${escapeHtml(stream.title||"No active broadcast")}
${stream.viewers??"—"} viewers${duration} live${stream.bitrate_kbps?`${Math.round(stream.bitrate_kbps)} kbps`:"—"} bitrate
${destinations.length?destinations.map(destination=>`
${escapeHtml(destination.name)}${escapeHtml(destination.status||"offline")}
`).join(""):`
No Restream destinations found
`}
`:`
${escapeHtml(stream.error||"Connect Restream in Setup to monitor livestreams")}
`; } diff --git a/app/static/display.js b/app/static/display.js index c1c2583..6f4f351 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -15,6 +15,12 @@ function fitDashboardToViewport(){ function queueDashboardFit(){cancelAnimationFrame(dashboardFitFrame);dashboardFitFrame=requestAnimationFrame(fitDashboardToViewport)} function updateNativeSpl(){const osm=lastState.osm||{};document.querySelectorAll("[data-spl-meter]").forEach(meter=>{const value=Number(osm[meter.dataset.osmKey||"a_fast"]),green=Number(meter.dataset.green),orange=Number(meter.dataset.orange),reading=meter.querySelector("[data-spl-value]"),status=meter.querySelector("[data-spl-status]");if(!osm.connected||!Number.isFinite(value)){if(reading)reading.textContent="--";if(status)status.textContent="Waiting for Open Sound Meter";meter.classList.remove("spl-green","spl-orange","spl-red");return}if(reading)reading.textContent=value.toFixed(1);meter.classList.toggle("spl-green",value<=green);meter.classList.toggle("spl-orange",value>green&&value<=orange);meter.classList.toggle("spl-red",value>orange);if(status)status.textContent=`${osm.source_name||"OSM source"} · ${meter.dataset.osmLabel||"level"}`})} const triggerScope=element=>({dashboard_slug:slug,widget_id:element.closest(".widget")?.dataset.widget||null}); +let lightingButtonsCache=null,lightingButtonsLoadedAt=0; +async function hydrateLightingControls(){ + const roots=[...document.querySelectorAll("[data-lighting-controls]")];if(!roots.length)return; + try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{const enabled=root.dataset.lightingEnabled==="true";root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`).join(""):`
No pages are exposed to external applications
`})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} +} +document.addEventListener("click",async event=>{const button=event.target.closest("[data-lighting-button]");if(!button||button.disabled)return;button.disabled=true;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"toggle",...triggerScope(button)})});lightingButtonsCache=null;await hydrateLightingControls()}catch(error){alert(error.message)}finally{button.disabled=false}}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppTrigger),playlistIndex=Number(button.dataset.ppPlaylistIndex);if(!Number.isInteger(index)||index<0||!Number.isInteger(playlistIndex)||playlistIndex<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-slide",{method:"POST",body:JSON.stringify({index,playlist_index:playlistIndex,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})});await refresh(true)}catch(error){alert(error.message)}finally{button.disabled=false}}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-playlist-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppPlaylistTrigger);if(!Number.isInteger(index)||index<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-playlist-item",{method:"POST",body:JSON.stringify({index,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})})}catch(error){alert(error.message)}finally{button.disabled=false}}); const keyboardStorageKey=widgetId=>`churchboard:${slug}:propresenter-keyboard:${widgetId}`; @@ -98,6 +104,7 @@ function render(){ if(changed){tickClocks();enhanceDynamicContent(root);syncPlaylistOperatorToggles(root)} queueDashboardFit(); updateNativeSpl(); + hydrateLightingControls(); } function objectId(value){if(!value||typeof value!=="object")return String(value);if(!objectIds.has(value))objectIds.set(value,nextObjectId++);return objectIds.get(value)} function leaderMicKey(mics){return(mics||[]).map(mic=>[mic.id,mic.name,mic.receiver,mic.assignment?.person_id,mic.assignment?.id,mic.assignment?.name,mic.assignment?.position_key])} diff --git a/app/static/editor.js b/app/static/editor.js index 8478124..a0d3b95 100644 --- a/app/static/editor.js +++ b/app/static/editor.js @@ -5,8 +5,8 @@ document.querySelector("#assignment-mode-label").insertAdjacentHTML("afterend",' document.querySelector("[name=order_limit]").closest("label").id="order-limit-label"; let dashboard,selected=null,dirty=false,catalogTeams=[],catalogError=""; const newId=type=>`${type}-${Date.now().toString(36)}`; -const defaults={clock:{w:3,h:2},service:{w:5,h:2},timing:{w:4,h:2},assignments:{w:7,h:6,team_ids:[],position_keys:[],position_labels:{},display_mode:"photos",card_grouping:"person",use_planning_center_icon:false,unassigned_media_title:"Icon"},slides:{w:6,h:4,show_notes:true,slide_mode:"image",slide_layout:"full",show_current:true,show_next:true,show_parts:true,show_slide_count:false},playlist:{w:6,h:7,allow_remote_trigger:true,keyboard_control:false,slide_size:120,item_size:48,marker_size:10,active_border_color:"#f5c400"},notes:{w:4,h:2},order:{w:5,h:3,display_mode:"current",limit:6,show_leader:false,show_mic:false},people:{w:4,h:4,team_ids:[],position_keys:[],position_labels:{}},spl:{w:4,h:3,green_max:75,orange_max:85,weighting:"A",response:"Fast"},controls:{w:4,h:2},restream:{w:5,h:4},obs:{w:5,h:4},propresenter_timers:{w:5,h:3},text:{w:4,h:2,text:"Custom text"}}; -const paletteTypes=["clock","service","timing","assignments","slides","playlist","notes","order","people","spl","controls","restream","obs","propresenter_timers","text"]; +const defaults={clock:{w:3,h:2},service:{w:5,h:2},timing:{w:4,h:2},assignments:{w:7,h:6,team_ids:[],position_keys:[],position_labels:{},display_mode:"photos",card_grouping:"person",use_planning_center_icon:false,unassigned_media_title:"Icon"},slides:{w:6,h:4,show_notes:true,slide_mode:"image",slide_layout:"full",show_current:true,show_next:true,show_parts:true,show_slide_count:false},playlist:{w:6,h:7,allow_remote_trigger:true,keyboard_control:false,slide_size:120,item_size:48,marker_size:10,active_border_color:"#f5c400"},notes:{w:4,h:2},order:{w:5,h:3,display_mode:"current",limit:6,show_leader:false,show_mic:false},people:{w:4,h:4,team_ids:[],position_keys:[],position_labels:{}},spl:{w:4,h:3,green_max:75,orange_max:85,weighting:"A",response:"Fast"},controls:{w:4,h:2},lighting:{w:6,h:4,allow_remote_trigger:true},restream:{w:5,h:4},obs:{w:5,h:4},propresenter_timers:{w:5,h:3},text:{w:4,h:2,text:"Custom text"}}; +const paletteTypes=["clock","service","timing","assignments","slides","playlist","notes","order","people","spl","controls","lighting","restream","obs","propresenter_timers","text"]; async function load(){ dashboard=await api(`/api/dashboards/${encodeURIComponent(slug)}`); diff --git a/app/store.py b/app/store.py index 3db4716..366fe91 100644 --- a/app/store.py +++ b/app/store.py @@ -65,6 +65,7 @@ def default_data() -> dict[str, Any]: }, "restream": {"enabled": False, "client_id": "", "client_secret": "", "access_token": "", "refresh_token": "", "access_token_expires_at": 0, "refresh_seconds": 5}, "obs": {"enabled": False, "host": "127.0.0.1", "port": 4455, "password": "", "refresh_seconds": 0.5, "dropped_frames_threshold": 2, "preview_url": ""}, + "lighting": {"enabled": False, "host": "127.0.0.1", "port": 7348, "password": ""}, "position_mic_map": {"Vox 1": "mic-1", "Vox 2": "mic-2"}, "manual_plan": None, }, @@ -93,7 +94,7 @@ def load(self) -> dict[str, Any]: baseline = default_data() baseline.update(raw) baseline["settings"] = {**default_data()["settings"], **raw.get("settings", {})} - for section in ("planning_center", "propresenter", "shure", "sennheiser", "open_sound_meter", "restream", "obs"): + for section in ("planning_center", "propresenter", "shure", "sennheiser", "open_sound_meter", "restream", "obs", "lighting"): baseline["settings"][section] = { **default_data()["settings"][section], **raw.get("settings", {}).get(section, {}), @@ -126,6 +127,8 @@ def load(self) -> dict[str, Any]: widget["settings"] = {"display_mode": "current", "limit": 6, "show_leader": False, "show_mic": False, **widget.get("settings", {})} if widget.get("type") == "spl": widget["settings"] = {"green_max": 75, "orange_max": 85, "reports_enabled": True, **widget.get("settings", {})} + if widget.get("type") == "lighting": + widget["settings"] = {"allow_remote_trigger": True, **widget.get("settings", {})} baseline["dashboards"] = [dashboard for dashboard in baseline["dashboards"] if dashboard.get("id") != "service-producer"] return baseline @@ -158,4 +161,7 @@ def public_settings(self) -> dict[str, Any]: obs = settings.get("obs", {}) obs["password_configured"] = bool(obs.get("password")) obs["password"] = "" + lighting = settings.get("lighting", {}) + lighting["password_configured"] = bool(lighting.get("password")) + lighting["password"] = "" return settings From a1f737e1d9348134673eeb548a370d785c6a92d0 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 14:29:38 -0400 Subject: [PATCH 02/23] Improve lighting connection errors --- app/main.py | 6 ++++-- app/services/thelightingcontroller.py | 26 ++++++++++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/app/main.py b/app/main.py index b1ab9ff..0df7a36 100644 --- a/app/main.py +++ b/app/main.py @@ -406,7 +406,8 @@ async def lighting_buttons(request: Request) -> dict: except ValueError as exc: raise HTTPException(400, str(exc)) from exc except Exception as exc: - raise HTTPException(502, f"Could not read lighting controls: {exc}") from exc + detail = str(exc) or exc.__class__.__name__ + raise HTTPException(502, f"Could not read lighting controls: {detail}") from exc return {"connected": True, "items": buttons, "count": len(buttons)} @@ -430,7 +431,8 @@ async def lighting_trigger_button(payload: LightingButtonTrigger, request: Reque except ValueError as exc: raise HTTPException(400, str(exc)) from exc except Exception as exc: - raise HTTPException(502, f"Could not trigger lighting button: {exc}") from exc + detail = str(exc) or exc.__class__.__name__ + raise HTTPException(502, f"Could not trigger lighting button: {detail}") from exc return {"ok": True, "name": payload.name, "mode": payload.mode} diff --git a/app/services/thelightingcontroller.py b/app/services/thelightingcontroller.py index 882db03..405b8cb 100644 --- a/app/services/thelightingcontroller.py +++ b/app/services/thelightingcontroller.py @@ -20,7 +20,7 @@ async def buttons(self) -> list[dict[str, Any]]: try: await self._send(writer, "BUTTON_LIST") while True: - line = await asyncio.wait_for(reader.readline(), timeout=3) + line = await self._read_line(reader, "the exposed button list") if not line: raise ConnectionError("The lighting controller closed the connection") text = line.decode("utf-8", "replace").rstrip("\r\n") @@ -53,11 +53,22 @@ async def trigger_button(self, name: str, mode: str = "toggle") -> None: async def _connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: host = str(self.settings.get("host") or "").strip() - port = int(self.settings.get("port") or 7348) - reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=3) + try: + port = int(self.settings.get("port") or 7348) + except (TypeError, ValueError) as exc: + raise ValueError("Enter a valid External App port number") from exc + if not 1 <= port <= 65535: + raise ValueError("External App port must be between 1 and 65535") + try: + reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=3) + except asyncio.TimeoutError as exc: + raise ConnectionError(f"Timed out connecting to {host}:{port}. Verify the computer address, port, and firewall.") from exc + except OSError as exc: + detail = exc.strerror or str(exc) or exc.__class__.__name__ + raise ConnectionError(f"Could not connect to {host}:{port}: {detail}") from exc await self._send(writer, "HELLO", "churchboard", str(self.settings.get("password") or "")) while True: - line = await asyncio.wait_for(reader.readline(), timeout=3) + line = await self._read_line(reader, "the ShowXpress/TLC sign-in reply") if not line: writer.close() await writer.wait_closed() @@ -70,6 +81,13 @@ async def _connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: await writer.wait_closed() raise ValueError(text.split("|", 1)[1] or "The lighting controller rejected the password") + @staticmethod + async def _read_line(reader: asyncio.StreamReader, waiting_for: str) -> bytes: + try: + return await asyncio.wait_for(reader.readline(), timeout=3) + except asyncio.TimeoutError as exc: + raise ConnectionError(f"Timed out waiting for {waiting_for}. Check that External App and External Control are enabled.") from exc + @staticmethod async def _send(writer: asyncio.StreamWriter, *parts: str) -> None: writer.write(("|".join(parts) + "\r\n").encode("ascii")) From 594bf9633d70f519ea5835660d5d3ebdcab35124 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 14:36:12 -0400 Subject: [PATCH 03/23] Use ShowXpress client identifier --- app/services/thelightingcontroller.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/services/thelightingcontroller.py b/app/services/thelightingcontroller.py index 405b8cb..24df153 100644 --- a/app/services/thelightingcontroller.py +++ b/app/services/thelightingcontroller.py @@ -8,6 +8,10 @@ class TheLightingControllerClient: """Client for TLC's External Application protocol (also used by ShowXpress).""" + # TLC/ShowXpress recognises this client identifier from its official Live + # Mobile/Companion-compatible External App protocol implementation. + APP_NAME = "thelightingcontrollerclient" + def __init__(self, settings: dict[str, Any]): self.settings = settings @@ -66,7 +70,7 @@ async def _connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: except OSError as exc: detail = exc.strerror or str(exc) or exc.__class__.__name__ raise ConnectionError(f"Could not connect to {host}:{port}: {detail}") from exc - await self._send(writer, "HELLO", "churchboard", str(self.settings.get("password") or "")) + await self._send(writer, "HELLO", self.APP_NAME, str(self.settings.get("password") or "")) while True: line = await self._read_line(reader, "the ShowXpress/TLC sign-in reply") if not line: From 326c4005e164e3ff59ba3090a717567513423933 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 14:48:28 -0400 Subject: [PATCH 04/23] Document ShowXpress setup steps --- README.md | 2 +- app/static/admin.html | 2 +- docs/CONFIGURATION.md | 22 ++++++++++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e470a58..0d0092c 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Open `http://127.0.0.1:8040/admin`, turn off demonstration data, and configure P ![ChurchBoard integrations setup](docs/screenshots/setup.jpg) -Start with [Configuration](docs/CONFIGURATION.md), then follow the detailed [Planning Center setup](docs/PLANNING_CENTER.md), [ProPresenter setup](docs/PROPRESENTER.md), [Open Sound Meter setup](docs/OPEN_SOUND_METER.md), and [Restream setup](docs/RESTREAM.md) guides. To control ShowXpress-compatible lighting software, enable its External App and External Control settings, configure the matching address, port (normally 7348), and password in Setup, then add a Lighting controls widget to a dashboard. Only pages marked visible to external applications appear in ChurchBoard. +Start with [Configuration](docs/CONFIGURATION.md), then follow the detailed [Planning Center setup](docs/PLANNING_CENTER.md), [ProPresenter setup](docs/PROPRESENTER.md), [Open Sound Meter setup](docs/OPEN_SOUND_METER.md), and [Restream setup](docs/RESTREAM.md) guides. To control ShowXpress-compatible lighting software, enable its External App setting, **restart the lighting application**, enable External Control with a password, then configure the matching address and port (normally 7348) in Setup. Only pages marked visible to external applications appear in ChurchBoard. ## Dashboard editing diff --git a/app/static/admin.html b/app/static/admin.html index a967752..2609826 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -8,7 +8,7 @@
ProPresenter

Slide triggering is enabled separately on each ProPresenter Playlist widget in the board editor.

Services LIVE automation

Planning Center-synced ProPresenter items match by their playlist item and position first, including non-song items such as Message. Strong title matches can also select non-song items when no synced link is available.

Restream

In your Restream app, add http://127.0.0.1:8040/api/integrations/restream/callback as a Redirect URI and select the scopes you require. ChurchBoard keeps the secret and OAuth tokens only on this computer.

OBS Studio

In OBS, open Tools → WebSocket Server Settings, enable the server, and enter the same port and password here. A preview image URL is optional; OBS status monitoring works without it.

-
Lighting control

Works with Chauvet ShowXpress, Showtec QuickDMX, Sweetlight Controller, and other TheLightingController apps. In the app, enable Preferences → Network – External App and Live Settings → Network – External Control; the usual port is 7348. Mark each desired page visible in the external application.

+
Lighting control

Works with Chauvet ShowXpress, Showtec QuickDMX, Sweetlight Controller, and other TheLightingController apps.

  1. In the lighting app, enable Preferences → Network → External App, then completely quit and restart the lighting app.
  2. In the Live tab, open Live Settings → External Control, enable it, and set a password.
  3. Mark every desired Live page Visible in external application.
  4. Enter the lighting computer’s LAN address, port 7348 (unless changed), and that password above. Save and load the buttons, then add a Lighting controls widget.
Wireless microphones

Add each receiver channel, select its receiver family, and assign its scheduled Planning Center position. Shure receivers use TCP port 2202; SLX-D requires Controller Access to be allowed in Advanced Settings. Sennheiser uses legacy SSC over UDP port 45.

Open Sound Meter

In Open Sound Meter, enable the Wi‑Fi icon’s Remote API Server. ChurchBoard listens for its UDP multicast level packets at 239.255.42.42:49007. The two computers must be on the same multicast-enabled network segment.

Waiting for OSM multicast data.
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3cb1880..b819cd2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -123,7 +123,21 @@ A Scheduled Positions & Mics widget uses a one-color ChurchBoard mark for an una The title is configurable per widget, so different dashboards can use different plan-media icons. ChurchBoard matches the title without regard to capitalization. -## 10. Connect Open Sound Meter +## 10. Connect ShowXpress and TLC lighting control + +ChurchBoard controls the exposed Live buttons in Chauvet ShowXpress, Showtec QuickDMX, Sweetlight Controller, and other applications based on TheLightingController. + +1. In the lighting app, enable **Main menu → Preferences → Network → External App**. +2. Completely quit and restart the lighting app. This step is required before it starts responding to External App connections. +3. Open the **Live** tab and choose **Live Settings → External Control**. Enable external control and set a password. +4. Mark every Live page that ChurchBoard should control **Visible in external application**. +5. In ChurchBoard Setup, enable **ShowXpress / TLC lighting control** and enter the lighting computer's LAN address, External App port (normally `7348`), and the same password. +6. Choose **Save & load lighting buttons**. The confirmation lists the number of buttons exposed by the lighting app. +7. Add a **Lighting controls** widget to a trusted operator dashboard. + +If ChurchBoard times out waiting for the sign-in reply, confirm the lighting app was restarted after enabling External App, both External App and External Control remain enabled, and ChurchBoard is using the lighting computer's LAN address rather than a browser or router address. + +## 11. Connect Open Sound Meter ChurchBoard can receive calibrated level data directly from [Open Sound Meter](https://opensoundmeter.com/) instead of measuring through the dashboard browser. @@ -137,7 +151,7 @@ ChurchBoard can receive calibrated level data directly from [Open Sound Meter](h ChurchBoard displays Open Sound Meter's selected level after applying the same SPL reference used by Open Sound Meter. It does not calibrate, smooth, or synthesize the measurement. See the complete [Open Sound Meter setup and data notes](OPEN_SOUND_METER.md) and [legal and operational limitations](../LEGAL.md). -## 11. Connect Restream +## 12. Connect Restream Restream monitoring shows whether a broadcast is live or upcoming, its elapsed time and available viewer count, and the state of each configured destination. @@ -150,7 +164,7 @@ Restream monitoring shows whether a broadcast is live or upcoming, its elapsed t ChurchBoard stores the client secret and OAuth tokens only in its local settings. Encoder bitrate and health are not exposed by the Restream public API, so ChurchBoard labels those values unavailable instead of estimating them. See [Restream setup](RESTREAM.md). -## 12. Connect OBS Studio +## 13. Connect OBS Studio ChurchBoard can monitor OBS Studio through its built-in WebSocket server without requiring Studio Mode. @@ -162,7 +176,7 @@ ChurchBoard can monitor OBS Studio through its built-in WebSocket server without The widget reports connection, streaming and recording state, elapsed time, output bitrate/statistics, dropped frames, and the configured preview. Keep OBS and ChurchBoard on the same trusted production network and do not expose the WebSocket port to the internet. -## 13. Open displays +## 14. Open displays Each dashboard has its own **Background color** picker at the top of the editor. The dashboard background, translucent liquid-glass widget surfaces, reflections, borders, and interface accents follow that color. Operational mic and SPL states remain green, yellow, or red so warnings are still immediately recognizable. From cbc0528671cdddea918bd2cb34832e189c9e4c4b Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 14:55:08 -0400 Subject: [PATCH 05/23] Improve lighting widget controls --- app/services/thelightingcontroller.py | 10 ++++++++-- app/static/common.js | 2 +- app/static/editor.js | 5 ++++- app/static/style.css | 1 + 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/services/thelightingcontroller.py b/app/services/thelightingcontroller.py index 24df153..401edca 100644 --- a/app/services/thelightingcontroller.py +++ b/app/services/thelightingcontroller.py @@ -46,11 +46,17 @@ async def trigger_button(self, name: str, mode: str = "toggle") -> None: button = next((item for item in buttons if item["name"] == name), None) if button is None: raise ValueError("That lighting button is no longer exposed by the controller") - command = "BUTTON_PRESS" if mode == "press" or (mode == "toggle" and not button["pressed"]) else "BUTTON_RELEASE" reader, writer = await self._connect() del reader try: - await self._send(writer, command, name) + if mode == "toggle" and button["flash"]: + # Flash buttons are momentary scenes; a click must not leave + # one held down after ChurchBoard's request finishes. + await self._send(writer, "BUTTON_PRESS", name) + await self._send(writer, "BUTTON_RELEASE", name) + else: + command = "BUTTON_PRESS" if mode == "press" or (mode == "toggle" and not button["pressed"]) else "BUTTON_RELEASE" + await self._send(writer, command, name) finally: writer.close() await writer.wait_closed() diff --git a/app/static/common.js b/app/static/common.js index f7c958e..1867391 100644 --- a/app/static/common.js +++ b/app/static/common.js @@ -162,7 +162,7 @@ const widgetMarkup = (widget, state) => { if(widget.type==="people") { const people=filteredPeople(settings,state);content=people.length?`
${people.map(person=>`
${person.photo?``:`${initials(person.name)}`}
${escapeHtml(person.name||"Unassigned")}${escapeHtml([person.position,person.team_name].filter(Boolean).join(" · "))}
`).join("")}
`:`
No scheduled people match these filters
`; } if(widget.type==="spl") { const green=Number(settings.green_max??75),orange=Number(settings.orange_max??85),weighting=["A","B","C","Z"].includes(settings.weighting)?settings.weighting:"A",response=settings.response==="Slow"?"Slow":"Fast",metricKey=`${weighting.toLowerCase()}_${response.toLowerCase()}`,metricLabel=`${weighting}-weighted ${response}`,osm=state.osm||{},value=Number(osm[metricKey]),reports=osm.reports_enabled!==false&&settings.reports_enabled!==false&&service.id?``:"";content=`
${Number.isFinite(value)?value.toFixed(1):"--"}dB
${metricLabel} · Green ≤ ${green}Orange ≤ ${orange}Red > ${orange}
${osm.connected?"Open Sound Meter connected":"Waiting for Open Sound Meter"}
${reports}
`; } if(widget.type==="controls") { const control=state.service_control||{},pcLive=state.planning_center_live||{},item=timing.current_item||{},isControlling=pcLive.enabled?!!pcLive.has_control:!!control.active,controlLabel=pcLive.enabled?"ProPresenter → Services LIVE":control.active?"Local control":"Following schedule",statusMessage=pcLive.enabled?pcLive.message||"":"";content=`
${escapeHtml(controlLabel)}${escapeHtml(item.title||"No current item")}
${escapeHtml(statusMessage)}
`; } - if(widget.type==="lighting") content=`
Loading exposed lighting controls…
`; + if(widget.type==="lighting") content=`
Loading exposed lighting controls…
`; if(widget.type==="person") { const person=(state.people||[]).find(p=>p.position===settings.position); content=person?`
${person.photo?``:initials(person.name)}
${escapeHtml(person.name)}
${escapeHtml(person.position)}
`:`
Choose a Planning Center position in the editor
`; } if(widget.type==="text") content=`
${escapeHtml(settings.text||"Custom text")}
`; if(widget.type==="restream") { const stream=state.restream||{},destinations=stream.destinations||[],status=String(stream.status||"offline").replace("-"," "),duration=formatDuration(stream.duration_seconds||0).replace(/^−/,"");content=stream.connected?`
${escapeHtml(status)}${escapeHtml(stream.title||"No active broadcast")}
${stream.viewers??"—"} viewers${duration} live${stream.bitrate_kbps?`${Math.round(stream.bitrate_kbps)} kbps`:"—"} bitrate
${destinations.length?destinations.map(destination=>`
${escapeHtml(destination.name)}${escapeHtml(destination.status||"offline")}
`).join(""):`
No Restream destinations found
`}
`:`
${escapeHtml(stream.error||"Connect Restream in Setup to monitor livestreams")}
`; } diff --git a/app/static/editor.js b/app/static/editor.js index a0d3b95..116ed4a 100644 --- a/app/static/editor.js +++ b/app/static/editor.js @@ -1,11 +1,12 @@ const slug=decodeURIComponent(location.pathname.split("/").pop()),grid=document.querySelector("#editor-grid"),form=document.querySelector("#inspector"); document.querySelector("#order-controls").insertAdjacentHTML("afterbegin",'

Choose a scrollable complete order, or fit every Planning Center item on the board without scrolling.

'); document.querySelector("#slides-controls").insertAdjacentHTML("afterend",''); +document.querySelector("#playlist-controls").insertAdjacentHTML("afterend",''); document.querySelector("#assignment-mode-label").insertAdjacentHTML("afterend",'

One card per person combines all selected roles for the same person. One card per position repeats a person when they serve in more than one selected role.

'); document.querySelector("[name=order_limit]").closest("label").id="order-limit-label"; let dashboard,selected=null,dirty=false,catalogTeams=[],catalogError=""; const newId=type=>`${type}-${Date.now().toString(36)}`; -const defaults={clock:{w:3,h:2},service:{w:5,h:2},timing:{w:4,h:2},assignments:{w:7,h:6,team_ids:[],position_keys:[],position_labels:{},display_mode:"photos",card_grouping:"person",use_planning_center_icon:false,unassigned_media_title:"Icon"},slides:{w:6,h:4,show_notes:true,slide_mode:"image",slide_layout:"full",show_current:true,show_next:true,show_parts:true,show_slide_count:false},playlist:{w:6,h:7,allow_remote_trigger:true,keyboard_control:false,slide_size:120,item_size:48,marker_size:10,active_border_color:"#f5c400"},notes:{w:4,h:2},order:{w:5,h:3,display_mode:"current",limit:6,show_leader:false,show_mic:false},people:{w:4,h:4,team_ids:[],position_keys:[],position_labels:{}},spl:{w:4,h:3,green_max:75,orange_max:85,weighting:"A",response:"Fast"},controls:{w:4,h:2},lighting:{w:6,h:4,allow_remote_trigger:true},restream:{w:5,h:4},obs:{w:5,h:4},propresenter_timers:{w:5,h:3},text:{w:4,h:2,text:"Custom text"}}; +const defaults={clock:{w:3,h:2},service:{w:5,h:2},timing:{w:4,h:2},assignments:{w:7,h:6,team_ids:[],position_keys:[],position_labels:{},display_mode:"photos",card_grouping:"person",use_planning_center_icon:false,unassigned_media_title:"Icon"},slides:{w:6,h:4,show_notes:true,slide_mode:"image",slide_layout:"full",show_current:true,show_next:true,show_parts:true,show_slide_count:false},playlist:{w:6,h:7,allow_remote_trigger:true,keyboard_control:false,slide_size:120,item_size:48,marker_size:10,active_border_color:"#f5c400"},notes:{w:4,h:2},order:{w:5,h:3,display_mode:"current",limit:6,show_leader:false,show_mic:false},people:{w:4,h:4,team_ids:[],position_keys:[],position_labels:{}},spl:{w:4,h:3,green_max:75,orange_max:85,weighting:"A",response:"Fast"},controls:{w:4,h:2},lighting:{w:6,h:4,allow_remote_trigger:true,page_size:18,scene_size:58},restream:{w:5,h:4},obs:{w:5,h:4},propresenter_timers:{w:5,h:3},text:{w:4,h:2,text:"Custom text"}}; const paletteTypes=["clock","service","timing","assignments","slides","playlist","notes","order","people","spl","controls","lighting","restream","obs","propresenter_timers","text"]; async function load(){ @@ -28,6 +29,7 @@ function select(id){ fields.use_planning_center_icon.checked=!!widget.settings.use_planning_center_icon;fields.unassigned_media_title.value=widget.settings.unassigned_media_title||"Icon";fields.unassigned_media_title.disabled=!fields.use_planning_center_icon.checked; fields.slide_layout.value=widget.settings.slide_layout==="previews_only"?"previews_only":"full";fields.slide_mode.value=widget.settings.slide_mode||"image";const showCurrent=widget.settings.show_current!==false,showNext=widget.settings.show_next!==false;fields.slide_visibility.value=showCurrent&&showNext?"both":showCurrent?"current":showNext?"next":"none";fields.show_parts.checked=widget.settings.show_parts!==false;fields.show_slide_count.checked=!!widget.settings.show_slide_count;fields.show_notes.checked=widget.settings.show_notes!==false; fields.playlist_slide_size.value=Math.max(80,Math.min(320,Number(widget.settings.slide_size)||120));fields.playlist_item_size.value=Math.max(40,Math.min(120,Number(widget.settings.item_size)||48));fields.playlist_marker_size.value=Math.max(8,Math.min(24,Number(widget.settings.marker_size)||10));fields.playlist_active_border_color.value=/^#[0-9a-f]{6}$/i.test(widget.settings.active_border_color||"")?widget.settings.active_border_color:"#f5c400"; + fields.lighting_page_size.value=Math.max(12,Math.min(40,Number(widget.settings.page_size)||18));fields.lighting_scene_size.value=Math.max(34,Math.min(120,Number(widget.settings.scene_size)||58));document.querySelector("#lighting-widget-controls").hidden=widget.type!=="lighting"; fields.order_display_mode.value=["full","fit"].includes(widget.settings.display_mode)?widget.settings.display_mode:"current";fields.order_limit.min=1;fields.order_limit.value=Math.max(1,Number(widget.settings.limit)||6);fields.show_leader.checked=!!widget.settings.show_leader;fields.show_mic.checked=!!widget.settings.show_mic; fields.spl_green.value=Number(widget.settings.green_max??75);fields.spl_orange.value=Number(widget.settings.orange_max??85);fields.spl_weighting.value=["A","B","C","Z"].includes(widget.settings.weighting)?widget.settings.weighting:"A";fields.spl_response.value=widget.settings.response==="Slow"?"Slow":"Fast"; document.querySelector("#assignment-controls").hidden=!isPositionWidget;document.querySelector("#assignment-mode-label").style.display=isAssignments?"grid":"none";document.querySelector("#unassigned-icon-controls").hidden=!isAssignments;document.querySelector("#slides-controls").hidden=widget.type!=="slides";document.querySelector("#playlist-controls").hidden=widget.type!=="playlist";document.querySelector("#order-controls").hidden=widget.type!=="order";document.querySelector("#order-limit-label").hidden=["full","fit"].includes(widget.settings.display_mode);document.querySelector("#spl-controls").hidden=widget.type!=="spl";fields.text.closest("label").style.display=widget.type==="text"?"grid":"none"; @@ -52,6 +54,7 @@ function beginPointer(event,widget,resizing){ paletteTypes.forEach(type=>{const name=widgetNames[type],button=document.createElement("button");button.className="palette-button";button.textContent=`+ ${name}`;button.onclick=()=>{const definition=defaults[type],bottom=Math.max(0,...dashboard.widgets.map(widget=>widget.y+widget.h));dashboard.widgets.push({id:newId(type),type,x:0,y:bottom,w:definition.w,h:definition.h,title:name,settings:Object.fromEntries(Object.entries(definition).filter(([key])=>!["w","h"].includes(key)))});changed();select(dashboard.widgets.at(-1).id)};document.querySelector("#widget-palette").append(button)}); form.addEventListener("input",event=>{if(event.target.matches("[data-team-id],[data-position-key]"))return;const widget=find(selected),fields=form.elements;widget.title=fields.title.value;widget.settings.show_title=fields.show_title.checked;widget.w=Math.max(1,Math.min(dashboard.columns,Number(fields.w.value)||1));widget.h=Math.max(1,Number(fields.h.value)||1);if(widget.type==="text")widget.settings.text=fields.text.value;if(widget.type==="slides")Object.assign(widget.settings,{slide_layout:fields.slide_layout.value,slide_mode:fields.slide_mode.value,show_current:["both","current"].includes(fields.slide_visibility.value),show_next:["both","next"].includes(fields.slide_visibility.value),show_parts:fields.show_parts.checked,show_slide_count:fields.show_slide_count.checked,show_notes:fields.show_notes.checked});if(widget.type==="playlist")Object.assign(widget.settings,{slide_size:Math.max(80,Math.min(320,Number(fields.playlist_slide_size.value)||120)),item_size:Math.max(40,Math.min(120,Number(fields.playlist_item_size.value)||48)),marker_size:Math.max(8,Math.min(24,Number(fields.playlist_marker_size.value)||10))});if(widget.type==="order")Object.assign(widget.settings,{display_mode:["full","fit"].includes(fields.order_display_mode.value)?fields.order_display_mode.value:"current",limit:Math.max(1,Math.min(20,Number(fields.order_limit.value)||6)),show_leader:fields.show_leader.checked,show_mic:fields.show_mic.checked});if(widget.type==="spl")Object.assign(widget.settings,{green_max:Number(fields.spl_green.value)||75,orange_max:Number(fields.spl_orange.value)||85,weighting:fields.spl_weighting.value,response:fields.spl_response.value});if(["assignments","mics"].includes(widget.type)){Object.assign(widget.settings,{display_mode:fields.assignment_mode.value,card_grouping:fields.assignment_grouping.value==="position"?"position":"person",use_planning_center_icon:fields.use_planning_center_icon.checked,unassigned_media_title:fields.unassigned_media_title.value.trim()||"Icon"});fields.unassigned_media_title.disabled=!fields.use_planning_center_icon.checked}if(widget.type==="order")document.querySelector("#order-limit-label").hidden=["full","fit"].includes(widget.settings.display_mode);setSlideControlState(widget,fields);changed();render()}); document.querySelector("#playlist-controls").addEventListener("input",event=>{if(event.target.name!=="playlist_active_border_color")return;const widget=find(selected);if(!widget||widget.type!=="playlist")return;widget.settings.active_border_color=event.target.value;changed();render()}); +document.querySelector("#lighting-widget-controls").addEventListener("input",()=>{const widget=find(selected);if(!widget||widget.type!=="lighting")return;widget.settings.page_size=Math.max(12,Math.min(40,Number(form.elements.lighting_page_size.value)||18));widget.settings.scene_size=Math.max(34,Math.min(120,Number(form.elements.lighting_scene_size.value)||58));changed();render()}); document.querySelector("#assignment-controls").addEventListener("change",event=>{ const widget=find(selected);if(!widget)return; if(event.target.matches("[data-team-id]")){widget.settings.team_ids=[...document.querySelectorAll("[data-team-id]:checked")].map(input=>input.dataset.teamId);const visible=new Set((widget.settings.team_ids.length?catalogTeams.filter(team=>widget.settings.team_ids.includes(String(team.id))):catalogTeams).flatMap(team=>team.positions.map(position=>position.key)));widget.settings.position_keys=(widget.settings.position_keys||[]).filter(key=>visible.has(key))} diff --git a/app/static/style.css b/app/static/style.css index b46d5ec..0653dfc 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -61,6 +61,7 @@ .dashboard .order-list li.active{background:linear-gradient(90deg,color-mix(in srgb,var(--board-effect) 18%,transparent),var(--glass-inner) 58%,color-mix(in srgb,var(--board-effect) 5%,transparent))} .dashboard .meter-track{background:color-mix(in srgb,var(--board-color) 54%,rgba(2,6,12,.72));box-shadow:inset 0 1px 2px #0008} .dashboard .control-buttons .take-control{background:linear-gradient(145deg,color-mix(in srgb,var(--board-effect) 18%,transparent),transparent 48%),linear-gradient(155deg,var(--glass-inner),var(--glass-inner-deep));border-color:color-mix(in srgb,var(--board-effect) 60%,var(--glass-inner-line))} +.lighting-controls{height:100%;overflow:auto;display:grid;align-content:start;gap:clamp(8px,2cqh,16px);padding:2px}.lighting-page{display:grid;gap:clamp(4px,1cqh,8px)}.lighting-page>strong{font-size:var(--lighting-page-size,18px);line-height:1.1}.lighting-page .control-buttons{grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--lighting-scene-size,58px)),1fr));gap:clamp(4px,1cqw,8px)}.lighting-page .control-buttons button{min-height:var(--lighting-scene-size,58px);white-space:normal;overflow-wrap:anywhere;font-size:clamp(.56rem,2.4cqw,.9rem)} .dashboard .talent-photo-placeholder .unassigned-board-icon{display:block;width:clamp(62px,48%,154px);min-width:0;min-height:0;aspect-ratio:1;padding:0;border:0;border-radius:0;background:var(--board-effect);box-shadow:none;opacity:.82;-webkit-mask:url("/static/churchboard-mark.svg") center/contain no-repeat;mask:url("/static/churchboard-mark.svg") center/contain no-repeat;filter:drop-shadow(0 9px 16px #0008)} .dashboard .order-list li:not(.active){opacity:.58} .dashboard .order-list li.active{color:#fff;border-left:3px solid #fff;background:linear-gradient(90deg,#ffffff2e,var(--glass-inner) 62%,#ffffff0d);box-shadow:inset 0 1px 0 #ffffff80,inset 0 0 20px #ffffff17,0 0 12px #ffffff0c} From 778ede169c9a9a9083e9e16eab1080daf2b1f43e Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 15:04:18 -0400 Subject: [PATCH 06/23] Enable lighting widget triggers --- app/main.py | 4 ++-- app/static/common.js | 2 +- app/static/display.js | 4 ++-- app/static/style.css | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/main.py b/app/main.py index 0df7a36..b621091 100644 --- a/app/main.py +++ b/app/main.py @@ -415,8 +415,8 @@ def require_lighting_widget_control(request: Request, dashboard_slug: str | None data = store_from(request).load() dashboard = next((item for item in data.get("dashboards", []) if str(item.get("slug") or "") == str(dashboard_slug or "")), None) widget = next((item for item in (dashboard or {}).get("widgets", []) if str(item.get("id") or "") == str(widget_id or "")), None) - if not widget or widget.get("type") != "lighting" or widget.get("settings", {}).get("allow_remote_trigger") is False: - raise HTTPException(403, "Lighting triggering is disabled in this widget's settings") + if not widget or widget.get("type") != "lighting": + raise HTTPException(403, "Lighting controls must be triggered from a Lighting controls widget") return data["settings"].get("lighting", {}) diff --git a/app/static/common.js b/app/static/common.js index 1867391..1143c6a 100644 --- a/app/static/common.js +++ b/app/static/common.js @@ -162,7 +162,7 @@ const widgetMarkup = (widget, state) => { if(widget.type==="people") { const people=filteredPeople(settings,state);content=people.length?`
${people.map(person=>`
${person.photo?``:`${initials(person.name)}`}
${escapeHtml(person.name||"Unassigned")}${escapeHtml([person.position,person.team_name].filter(Boolean).join(" · "))}
`).join("")}
`:`
No scheduled people match these filters
`; } if(widget.type==="spl") { const green=Number(settings.green_max??75),orange=Number(settings.orange_max??85),weighting=["A","B","C","Z"].includes(settings.weighting)?settings.weighting:"A",response=settings.response==="Slow"?"Slow":"Fast",metricKey=`${weighting.toLowerCase()}_${response.toLowerCase()}`,metricLabel=`${weighting}-weighted ${response}`,osm=state.osm||{},value=Number(osm[metricKey]),reports=osm.reports_enabled!==false&&settings.reports_enabled!==false&&service.id?``:"";content=`
${Number.isFinite(value)?value.toFixed(1):"--"}dB
${metricLabel} · Green ≤ ${green}Orange ≤ ${orange}Red > ${orange}
${osm.connected?"Open Sound Meter connected":"Waiting for Open Sound Meter"}
${reports}
`; } if(widget.type==="controls") { const control=state.service_control||{},pcLive=state.planning_center_live||{},item=timing.current_item||{},isControlling=pcLive.enabled?!!pcLive.has_control:!!control.active,controlLabel=pcLive.enabled?"ProPresenter → Services LIVE":control.active?"Local control":"Following schedule",statusMessage=pcLive.enabled?pcLive.message||"":"";content=`
${escapeHtml(controlLabel)}${escapeHtml(item.title||"No current item")}
${escapeHtml(statusMessage)}
`; } - if(widget.type==="lighting") content=`
Loading exposed lighting controls…
`; + if(widget.type==="lighting") content=`
Loading exposed lighting controls…
`; if(widget.type==="person") { const person=(state.people||[]).find(p=>p.position===settings.position); content=person?`
${person.photo?``:initials(person.name)}
${escapeHtml(person.name)}
${escapeHtml(person.position)}
`:`
Choose a Planning Center position in the editor
`; } if(widget.type==="text") content=`
${escapeHtml(settings.text||"Custom text")}
`; if(widget.type==="restream") { const stream=state.restream||{},destinations=stream.destinations||[],status=String(stream.status||"offline").replace("-"," "),duration=formatDuration(stream.duration_seconds||0).replace(/^−/,"");content=stream.connected?`
${escapeHtml(status)}${escapeHtml(stream.title||"No active broadcast")}
${stream.viewers??"—"} viewers${duration} live${stream.bitrate_kbps?`${Math.round(stream.bitrate_kbps)} kbps`:"—"} bitrate
${destinations.length?destinations.map(destination=>`
${escapeHtml(destination.name)}${escapeHtml(destination.status||"offline")}
`).join(""):`
No Restream destinations found
`}
`:`
${escapeHtml(stream.error||"Connect Restream in Setup to monitor livestreams")}
`; } diff --git a/app/static/display.js b/app/static/display.js index 6f4f351..9cd6ecf 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -18,9 +18,9 @@ const triggerScope=element=>({dashboard_slug:slug,widget_id:element.closest(".wi let lightingButtonsCache=null,lightingButtonsLoadedAt=0; async function hydrateLightingControls(){ const roots=[...document.querySelectorAll("[data-lighting-controls]")];if(!roots.length)return; - try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{const enabled=root.dataset.lightingEnabled==="true";root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`).join(""):`
No pages are exposed to external applications
`})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} + try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} } -document.addEventListener("click",async event=>{const button=event.target.closest("[data-lighting-button]");if(!button||button.disabled)return;button.disabled=true;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"toggle",...triggerScope(button)})});lightingButtonsCache=null;await hydrateLightingControls()}catch(error){alert(error.message)}finally{button.disabled=false}}); +document.addEventListener("click",async event=>{const button=event.target.closest("[data-lighting-button]");if(!button||button.disabled)return;button.disabled=true;const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");if(status)status.textContent=`Triggering ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"toggle",...triggerScope(button)})});if(status)status.textContent=`Triggered ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;alert(error.message)}finally{button.disabled=false}}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppTrigger),playlistIndex=Number(button.dataset.ppPlaylistIndex);if(!Number.isInteger(index)||index<0||!Number.isInteger(playlistIndex)||playlistIndex<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-slide",{method:"POST",body:JSON.stringify({index,playlist_index:playlistIndex,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})});await refresh(true)}catch(error){alert(error.message)}finally{button.disabled=false}}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-playlist-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppPlaylistTrigger);if(!Number.isInteger(index)||index<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-playlist-item",{method:"POST",body:JSON.stringify({index,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})})}catch(error){alert(error.message)}finally{button.disabled=false}}); const keyboardStorageKey=widgetId=>`churchboard:${slug}:propresenter-keyboard:${widgetId}`; diff --git a/app/static/style.css b/app/static/style.css index 0653dfc..2aa24ec 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -61,7 +61,7 @@ .dashboard .order-list li.active{background:linear-gradient(90deg,color-mix(in srgb,var(--board-effect) 18%,transparent),var(--glass-inner) 58%,color-mix(in srgb,var(--board-effect) 5%,transparent))} .dashboard .meter-track{background:color-mix(in srgb,var(--board-color) 54%,rgba(2,6,12,.72));box-shadow:inset 0 1px 2px #0008} .dashboard .control-buttons .take-control{background:linear-gradient(145deg,color-mix(in srgb,var(--board-effect) 18%,transparent),transparent 48%),linear-gradient(155deg,var(--glass-inner),var(--glass-inner-deep));border-color:color-mix(in srgb,var(--board-effect) 60%,var(--glass-inner-line))} -.lighting-controls{height:100%;overflow:auto;display:grid;align-content:start;gap:clamp(8px,2cqh,16px);padding:2px}.lighting-page{display:grid;gap:clamp(4px,1cqh,8px)}.lighting-page>strong{font-size:var(--lighting-page-size,18px);line-height:1.1}.lighting-page .control-buttons{grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--lighting-scene-size,58px)),1fr));gap:clamp(4px,1cqw,8px)}.lighting-page .control-buttons button{min-height:var(--lighting-scene-size,58px);white-space:normal;overflow-wrap:anywhere;font-size:clamp(.56rem,2.4cqw,.9rem)} +.lighting-controls{height:100%;overflow:auto;display:grid;align-content:start;gap:clamp(8px,2cqh,16px);padding:2px}.lighting-page{display:grid;gap:clamp(4px,1cqh,8px)}.lighting-page>strong{font-size:var(--lighting-page-size,18px);line-height:1.1}.lighting-page .control-buttons{grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--lighting-scene-size,58px)),1fr));gap:clamp(4px,1cqw,8px)}.lighting-page .control-buttons button{min-height:var(--lighting-scene-size,58px);white-space:normal;overflow-wrap:anywhere;font-size:clamp(.56rem,2.4cqw,.9rem)}.lighting-status{min-height:1.2em;color:var(--muted);font-size:clamp(.52rem,2cqw,.72rem);text-align:center}.lighting-status:not(:empty){color:var(--accent)} .dashboard .talent-photo-placeholder .unassigned-board-icon{display:block;width:clamp(62px,48%,154px);min-width:0;min-height:0;aspect-ratio:1;padding:0;border:0;border-radius:0;background:var(--board-effect);box-shadow:none;opacity:.82;-webkit-mask:url("/static/churchboard-mark.svg") center/contain no-repeat;mask:url("/static/churchboard-mark.svg") center/contain no-repeat;filter:drop-shadow(0 9px 16px #0008)} .dashboard .order-list li:not(.active){opacity:.58} .dashboard .order-list li.active{color:#fff;border-left:3px solid #fff;background:linear-gradient(90deg,#ffffff2e,var(--glass-inner) 62%,#ffffff0d);box-shadow:inset 0 1px 0 #ffffff80,inset 0 0 20px #ffffff17,0 0 12px #ffffff0c} From c19ba58bccb943e88ae9433f2e64178199067ec3 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 15:09:03 -0400 Subject: [PATCH 07/23] Mirror lighting controller page layout --- app/services/thelightingcontroller.py | 3 ++- app/static/display.js | 2 +- app/static/style.css | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/services/thelightingcontroller.py b/app/services/thelightingcontroller.py index 401edca..597e22d 100644 --- a/app/services/thelightingcontroller.py +++ b/app/services/thelightingcontroller.py @@ -112,12 +112,13 @@ def _parse_buttons(payload: str) -> list[dict[str, Any]]: buttons: list[dict[str, Any]] = [] for page in root.findall("page"): page_name = page.get("name") or "Lighting" + page_columns = int(page.get("columns") or 0) for element in page.findall("button"): name = (element.text or "").strip() if not name: continue buttons.append({ - "name": name, "page": page_name, "column": int(element.get("column") or 0), + "name": name, "page": page_name, "page_columns": page_columns, "column": int(element.get("column") or 0), "line": int(element.get("line") or 0), "color": element.get("color") or "#4c6b8a", "pressed": element.get("pressed") == "1", "flash": element.get("flash") == "1", }) diff --git a/app/static/display.js b/app/static/display.js index 9cd6ecf..7c0b133 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -18,7 +18,7 @@ const triggerScope=element=>({dashboard_slug:slug,widget_id:element.closest(".wi let lightingButtonsCache=null,lightingButtonsLoadedAt=0; async function hydrateLightingControls(){ const roots=[...document.querySelectorAll("[data-lighting-controls]")];if(!roots.length)return; - try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} + try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||Number(button.column)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} } document.addEventListener("click",async event=>{const button=event.target.closest("[data-lighting-button]");if(!button||button.disabled)return;button.disabled=true;const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");if(status)status.textContent=`Triggering ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"toggle",...triggerScope(button)})});if(status)status.textContent=`Triggered ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;alert(error.message)}finally{button.disabled=false}}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppTrigger),playlistIndex=Number(button.dataset.ppPlaylistIndex);if(!Number.isInteger(index)||index<0||!Number.isInteger(playlistIndex)||playlistIndex<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-slide",{method:"POST",body:JSON.stringify({index,playlist_index:playlistIndex,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})});await refresh(true)}catch(error){alert(error.message)}finally{button.disabled=false}}); diff --git a/app/static/style.css b/app/static/style.css index 2aa24ec..e460feb 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -61,7 +61,7 @@ .dashboard .order-list li.active{background:linear-gradient(90deg,color-mix(in srgb,var(--board-effect) 18%,transparent),var(--glass-inner) 58%,color-mix(in srgb,var(--board-effect) 5%,transparent))} .dashboard .meter-track{background:color-mix(in srgb,var(--board-color) 54%,rgba(2,6,12,.72));box-shadow:inset 0 1px 2px #0008} .dashboard .control-buttons .take-control{background:linear-gradient(145deg,color-mix(in srgb,var(--board-effect) 18%,transparent),transparent 48%),linear-gradient(155deg,var(--glass-inner),var(--glass-inner-deep));border-color:color-mix(in srgb,var(--board-effect) 60%,var(--glass-inner-line))} -.lighting-controls{height:100%;overflow:auto;display:grid;align-content:start;gap:clamp(8px,2cqh,16px);padding:2px}.lighting-page{display:grid;gap:clamp(4px,1cqh,8px)}.lighting-page>strong{font-size:var(--lighting-page-size,18px);line-height:1.1}.lighting-page .control-buttons{grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--lighting-scene-size,58px)),1fr));gap:clamp(4px,1cqw,8px)}.lighting-page .control-buttons button{min-height:var(--lighting-scene-size,58px);white-space:normal;overflow-wrap:anywhere;font-size:clamp(.56rem,2.4cqw,.9rem)}.lighting-status{min-height:1.2em;color:var(--muted);font-size:clamp(.52rem,2cqw,.72rem);text-align:center}.lighting-status:not(:empty){color:var(--accent)} +.lighting-controls{height:100%;overflow:auto;display:grid;align-content:start;gap:clamp(8px,2cqh,16px);padding:2px}.lighting-page{display:grid;gap:clamp(4px,1cqh,8px)}.lighting-page>strong{font-size:var(--lighting-page-size,18px);line-height:1.1}.lighting-page .control-buttons{grid-template-columns:repeat(var(--lighting-columns,3),minmax(min(100%,var(--lighting-scene-size,58px)),1fr));grid-auto-rows:var(--lighting-scene-size,58px);gap:clamp(4px,1cqw,8px)}.lighting-page .control-buttons button{min-height:var(--lighting-scene-size,58px);white-space:normal;overflow-wrap:anywhere;font-size:clamp(.56rem,2.4cqw,.9rem)}.lighting-status{min-height:1.2em;color:var(--muted);font-size:clamp(.52rem,2cqw,.72rem);text-align:center}.lighting-status:not(:empty){color:var(--accent)} .dashboard .talent-photo-placeholder .unassigned-board-icon{display:block;width:clamp(62px,48%,154px);min-width:0;min-height:0;aspect-ratio:1;padding:0;border:0;border-radius:0;background:var(--board-effect);box-shadow:none;opacity:.82;-webkit-mask:url("/static/churchboard-mark.svg") center/contain no-repeat;mask:url("/static/churchboard-mark.svg") center/contain no-repeat;filter:drop-shadow(0 9px 16px #0008)} .dashboard .order-list li:not(.active){opacity:.58} .dashboard .order-list li.active{color:#fff;border-left:3px solid #fff;background:linear-gradient(90deg,#ffffff2e,var(--glass-inner) 62%,#ffffff0d);box-shadow:inset 0 1px 0 #ffffff80,inset 0 0 20px #ffffff17,0 0 12px #ffffff0c} From 23e753c232fbe3c70bf84c113fef32bcba5b0a3c Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 15:15:07 -0400 Subject: [PATCH 08/23] Keep lighting triggers in one session --- app/services/thelightingcontroller.py | 35 +++++++++++++++------------ 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/app/services/thelightingcontroller.py b/app/services/thelightingcontroller.py index 597e22d..49fc98a 100644 --- a/app/services/thelightingcontroller.py +++ b/app/services/thelightingcontroller.py @@ -22,16 +22,7 @@ def configured(self) -> bool: async def buttons(self) -> list[dict[str, Any]]: reader, writer = await self._connect() try: - await self._send(writer, "BUTTON_LIST") - while True: - line = await self._read_line(reader, "the exposed button list") - if not line: - raise ConnectionError("The lighting controller closed the connection") - text = line.decode("utf-8", "replace").rstrip("\r\n") - if text.startswith("ERROR|"): - raise ValueError(text.split("|", 1)[1] or "The lighting controller rejected the request") - if text.startswith("BUTTON_LIST|"): - return self._parse_buttons(text.split("|", 1)[1]) + return await self._button_list(reader, writer) finally: writer.close() await writer.wait_closed() @@ -41,14 +32,14 @@ async def trigger_button(self, name: str, mode: str = "toggle") -> None: raise ValueError("Invalid lighting button name") if mode not in {"press", "release", "toggle"}: raise ValueError("Lighting button mode must be press, release, or toggle") - # Query first so ChurchBoard never becomes an arbitrary TCP command proxy. - buttons = await self.buttons() - button = next((item for item in buttons if item["name"] == name), None) - if button is None: - raise ValueError("That lighting button is no longer exposed by the controller") reader, writer = await self._connect() - del reader try: + # Keep discovery and the command in the same authenticated session. + # TLC installations commonly accept only one External App client. + buttons = await self._button_list(reader, writer) + button = next((item for item in buttons if item["name"] == name), None) + if button is None: + raise ValueError("That lighting button is no longer exposed by the controller") if mode == "toggle" and button["flash"]: # Flash buttons are momentary scenes; a click must not leave # one held down after ChurchBoard's request finishes. @@ -61,6 +52,18 @@ async def trigger_button(self, name: str, mode: str = "toggle") -> None: writer.close() await writer.wait_closed() + async def _button_list(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> list[dict[str, Any]]: + await self._send(writer, "BUTTON_LIST") + while True: + line = await self._read_line(reader, "the exposed button list") + if not line: + raise ConnectionError("The lighting controller closed the connection") + text = line.decode("utf-8", "replace").rstrip("\r\n") + if text.startswith("ERROR|"): + raise ValueError(text.split("|", 1)[1] or "The lighting controller rejected the request") + if text.startswith("BUTTON_LIST|"): + return self._parse_buttons(text.split("|", 1)[1]) + async def _connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: host = str(self.settings.get("host") or "").strip() try: From 91ab17259901c3e0e6c3aa9b1620503220056376 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 15:23:01 -0400 Subject: [PATCH 09/23] Send lighting press and release commands --- app/static/display.js | 8 +++++++- app/static/style.css | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/static/display.js b/app/static/display.js index 7c0b133..7153484 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -20,7 +20,13 @@ async function hydrateLightingControls(){ const roots=[...document.querySelectorAll("[data-lighting-controls]")];if(!roots.length)return; try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||Number(button.column)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} } -document.addEventListener("click",async event=>{const button=event.target.closest("[data-lighting-button]");if(!button||button.disabled)return;button.disabled=true;const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");if(status)status.textContent=`Triggering ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"toggle",...triggerScope(button)})});if(status)status.textContent=`Triggered ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;alert(error.message)}finally{button.disabled=false}}); +const lightingRequests=new WeakMap(); +function sendLightingCommand(button,mode){const previous=lightingRequests.get(button)||Promise.resolve(),next=previous.catch(()=>{}).then(async()=>{const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");if(status)status.textContent=`${mode==="press"?"Pressing":"Releasing"} ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode,...triggerScope(button)})});if(status)status.textContent=`${mode==="press"?"Pressed":"Released"} ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;throw error}});lightingRequests.set(button,next);next.catch(error=>console.error(error));return next} +document.addEventListener("pointerdown",event=>{const button=event.target.closest("[data-lighting-button]");if(!button||event.button!==0||button.dataset.lightingPointer)return;event.preventDefault();button.dataset.lightingPointer=String(event.pointerId);button.classList.add("pressed");button.setPointerCapture?.(event.pointerId);sendLightingCommand(button,"press")}); +function releaseLightingPointer(event){const button=event.target.closest("[data-lighting-button]");if(!button||button.dataset.lightingPointer!==String(event.pointerId))return;button.classList.remove("pressed");delete button.dataset.lightingPointer;sendLightingCommand(button,"release")} +document.addEventListener("pointerup",releaseLightingPointer);document.addEventListener("pointercancel",releaseLightingPointer); +document.addEventListener("keydown",event=>{const button=event.target.closest("[data-lighting-button]");if(!button||event.repeat||!["Enter"," "].includes(event.key)||button.dataset.lightingKey)return;event.preventDefault();button.dataset.lightingKey=event.key;button.classList.add("pressed");sendLightingCommand(button,"press")}); +document.addEventListener("keyup",event=>{const button=event.target.closest("[data-lighting-button]");if(!button||button.dataset.lightingKey!==event.key)return;event.preventDefault();delete button.dataset.lightingKey;button.classList.remove("pressed");sendLightingCommand(button,"release")}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppTrigger),playlistIndex=Number(button.dataset.ppPlaylistIndex);if(!Number.isInteger(index)||index<0||!Number.isInteger(playlistIndex)||playlistIndex<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-slide",{method:"POST",body:JSON.stringify({index,playlist_index:playlistIndex,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})});await refresh(true)}catch(error){alert(error.message)}finally{button.disabled=false}}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-playlist-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppPlaylistTrigger);if(!Number.isInteger(index)||index<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-playlist-item",{method:"POST",body:JSON.stringify({index,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})})}catch(error){alert(error.message)}finally{button.disabled=false}}); const keyboardStorageKey=widgetId=>`churchboard:${slug}:propresenter-keyboard:${widgetId}`; diff --git a/app/static/style.css b/app/static/style.css index e460feb..dd9fdeb 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -62,6 +62,7 @@ .dashboard .meter-track{background:color-mix(in srgb,var(--board-color) 54%,rgba(2,6,12,.72));box-shadow:inset 0 1px 2px #0008} .dashboard .control-buttons .take-control{background:linear-gradient(145deg,color-mix(in srgb,var(--board-effect) 18%,transparent),transparent 48%),linear-gradient(155deg,var(--glass-inner),var(--glass-inner-deep));border-color:color-mix(in srgb,var(--board-effect) 60%,var(--glass-inner-line))} .lighting-controls{height:100%;overflow:auto;display:grid;align-content:start;gap:clamp(8px,2cqh,16px);padding:2px}.lighting-page{display:grid;gap:clamp(4px,1cqh,8px)}.lighting-page>strong{font-size:var(--lighting-page-size,18px);line-height:1.1}.lighting-page .control-buttons{grid-template-columns:repeat(var(--lighting-columns,3),minmax(min(100%,var(--lighting-scene-size,58px)),1fr));grid-auto-rows:var(--lighting-scene-size,58px);gap:clamp(4px,1cqw,8px)}.lighting-page .control-buttons button{min-height:var(--lighting-scene-size,58px);white-space:normal;overflow-wrap:anywhere;font-size:clamp(.56rem,2.4cqw,.9rem)}.lighting-status{min-height:1.2em;color:var(--muted);font-size:clamp(.52rem,2cqw,.72rem);text-align:center}.lighting-status:not(:empty){color:var(--accent)} +.lighting-page .control-buttons button.pressed{filter:brightness(.72);transform:translateY(1px);box-shadow:inset 0 2px 6px #0008} .dashboard .talent-photo-placeholder .unassigned-board-icon{display:block;width:clamp(62px,48%,154px);min-width:0;min-height:0;aspect-ratio:1;padding:0;border:0;border-radius:0;background:var(--board-effect);box-shadow:none;opacity:.82;-webkit-mask:url("/static/churchboard-mark.svg") center/contain no-repeat;mask:url("/static/churchboard-mark.svg") center/contain no-repeat;filter:drop-shadow(0 9px 16px #0008)} .dashboard .order-list li:not(.active){opacity:.58} .dashboard .order-list li.active{color:#fff;border-left:3px solid #fff;background:linear-gradient(90deg,#ffffff2e,var(--glass-inner) 62%,#ffffff0d);box-shadow:inset 0 1px 0 #ffffff80,inset 0 0 20px #ffffff17,0 0 12px #ffffff0c} From d238f9c16f2a1d4f7bc1f5faadf595622a731eef Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 15:30:01 -0400 Subject: [PATCH 10/23] Keep lighting controls stable during refresh --- app/static/display.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/static/display.js b/app/static/display.js index 7153484..3b13294 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -117,6 +117,7 @@ function leaderMicKey(mics){return(mics||[]).map(mic=>[mic.id,mic.name,mic.recei function widgetStateKey(widget,state){ const timing=state.timing||{},service=state.service||{},pp=state.propresenter||{},settings=widget.settings||{}; if(widget.type==="clock"||widget.type==="spl"||widget.type==="text")return`${widget.type}:static`; + if(widget.type==="lighting")return`lighting:${JSON.stringify([settings.page_size,settings.scene_size])}`; if(widget.type==="service")return`service:${objectId(service)}:${timing.source||""}:${timing.state||""}`; if(widget.type==="timing")return`timing:${String(timing.current_item?.id||"")}:${timing.rehearsal===true}`; if(["assignments","mics"].includes(widget.type))return`${widget.type}:${JSON.stringify([state.people||[],state.mics||[],state.planning_center_media||{}])}`; From adb4d04e5c8fda5967a0b565147a2e3c1403f1eb Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 15:35:41 -0400 Subject: [PATCH 11/23] Toggle standard lighting scenes --- app/static/display.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/static/display.js b/app/static/display.js index 3b13294..b8f78bb 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -21,12 +21,8 @@ async function hydrateLightingControls(){ try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||Number(button.column)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} } const lightingRequests=new WeakMap(); -function sendLightingCommand(button,mode){const previous=lightingRequests.get(button)||Promise.resolve(),next=previous.catch(()=>{}).then(async()=>{const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");if(status)status.textContent=`${mode==="press"?"Pressing":"Releasing"} ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode,...triggerScope(button)})});if(status)status.textContent=`${mode==="press"?"Pressed":"Released"} ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;throw error}});lightingRequests.set(button,next);next.catch(error=>console.error(error));return next} -document.addEventListener("pointerdown",event=>{const button=event.target.closest("[data-lighting-button]");if(!button||event.button!==0||button.dataset.lightingPointer)return;event.preventDefault();button.dataset.lightingPointer=String(event.pointerId);button.classList.add("pressed");button.setPointerCapture?.(event.pointerId);sendLightingCommand(button,"press")}); -function releaseLightingPointer(event){const button=event.target.closest("[data-lighting-button]");if(!button||button.dataset.lightingPointer!==String(event.pointerId))return;button.classList.remove("pressed");delete button.dataset.lightingPointer;sendLightingCommand(button,"release")} -document.addEventListener("pointerup",releaseLightingPointer);document.addEventListener("pointercancel",releaseLightingPointer); -document.addEventListener("keydown",event=>{const button=event.target.closest("[data-lighting-button]");if(!button||event.repeat||!["Enter"," "].includes(event.key)||button.dataset.lightingKey)return;event.preventDefault();button.dataset.lightingKey=event.key;button.classList.add("pressed");sendLightingCommand(button,"press")}); -document.addEventListener("keyup",event=>{const button=event.target.closest("[data-lighting-button]");if(!button||button.dataset.lightingKey!==event.key)return;event.preventDefault();delete button.dataset.lightingKey;button.classList.remove("pressed");sendLightingCommand(button,"release")}); +function toggleLightingScene(button){const previous=lightingRequests.get(button)||Promise.resolve(),next=previous.catch(()=>{}).then(async()=>{const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");button.classList.add("pressed");if(status)status.textContent=`Triggering ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"toggle",...triggerScope(button)})});if(status)status.textContent=`Triggered ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;throw error}finally{button.classList.remove("pressed")}});lightingRequests.set(button,next);next.catch(error=>console.error(error));return next} +document.addEventListener("click",event=>{const button=event.target.closest("[data-lighting-button]");if(!button||button.disabled)return;toggleLightingScene(button)}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppTrigger),playlistIndex=Number(button.dataset.ppPlaylistIndex);if(!Number.isInteger(index)||index<0||!Number.isInteger(playlistIndex)||playlistIndex<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-slide",{method:"POST",body:JSON.stringify({index,playlist_index:playlistIndex,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})});await refresh(true)}catch(error){alert(error.message)}finally{button.disabled=false}}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-playlist-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppPlaylistTrigger);if(!Number.isInteger(index)||index<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-playlist-item",{method:"POST",body:JSON.stringify({index,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})})}catch(error){alert(error.message)}finally{button.disabled=false}}); const keyboardStorageKey=widgetId=>`churchboard:${slug}:propresenter-keyboard:${widgetId}`; From 35b6c6bea78a20b2b30e25f01a356a76dbb4daf1 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 15:44:15 -0400 Subject: [PATCH 12/23] Align zero-based TLC button positions --- app/services/thelightingcontroller.py | 19 ++++++++++++++++--- tests/test_core.py | 7 +++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/services/thelightingcontroller.py b/app/services/thelightingcontroller.py index 49fc98a..54f2460 100644 --- a/app/services/thelightingcontroller.py +++ b/app/services/thelightingcontroller.py @@ -116,13 +116,26 @@ def _parse_buttons(payload: str) -> list[dict[str, Any]]: for page in root.findall("page"): page_name = page.get("name") or "Lighting" page_columns = int(page.get("columns") or 0) - for element in page.findall("button"): + page_buttons = list(page.findall("button")) + # TLC's external-app XML uses zero-based positions on some + # releases, whereas CSS grid lines are one-based. Treat a zero + # in either coordinate as an unambiguous zero-based page so two + # adjacent TLC buttons cannot be rendered on top of each other. + zero_based = any( + element.get(axis) == "0" + for element in page_buttons + for axis in ("column", "line") + ) + coordinate_offset = 1 if zero_based else 0 + for element in page_buttons: name = (element.text or "").strip() if not name: continue buttons.append({ - "name": name, "page": page_name, "page_columns": page_columns, "column": int(element.get("column") or 0), - "line": int(element.get("line") or 0), "color": element.get("color") or "#4c6b8a", + "name": name, "page": page_name, "page_columns": page_columns, + "column": int(element.get("column") or 0) + coordinate_offset, + "line": int(element.get("line") or 0) + coordinate_offset, + "color": element.get("color") or "#4c6b8a", "pressed": element.get("pressed") == "1", "flash": element.get("flash") == "1", }) return sorted(buttons, key=lambda item: (item["page"].casefold(), item["line"], item["column"], item["name"].casefold())) diff --git a/tests/test_core.py b/tests/test_core.py index c0bd570..1f4509b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,10 +14,17 @@ from app.services.propresenter import ProPresenterClient from app.services.restream import RestreamClient from app.services.runtime import RuntimeService +from app.services.thelightingcontroller import TheLightingControllerClient from app.store import ConfigStore class StoreTests(unittest.TestCase): + def test_lighting_zero_based_button_positions_are_converted_for_css_grid(self): + buttons = TheLightingControllerClient._parse_buttons( + '' + ) + self.assertEqual([(button["name"], button["column"], button["line"]) for button in buttons], [("Scene 1", 1, 1), ("Scene 2", 2, 1)]) + def test_new_store_contains_destination_dashboards(self): with tempfile.TemporaryDirectory() as directory: store = ConfigStore(Path(directory) / "state.json") From 661263bf247d4bb92c2d1a1c333d1bb07d0085ed Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 15:48:56 -0400 Subject: [PATCH 13/23] Prevent TLC scene button overlap --- app/static/display.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/static/display.js b/app/static/display.js index b8f78bb..a4dbbb3 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -18,7 +18,7 @@ const triggerScope=element=>({dashboard_slug:slug,widget_id:element.closest(".wi let lightingButtonsCache=null,lightingButtonsLoadedAt=0; async function hydrateLightingControls(){ const roots=[...document.querySelectorAll("[data-lighting-controls]")];if(!roots.length)return; - try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||Number(button.column)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} + try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} } const lightingRequests=new WeakMap(); function toggleLightingScene(button){const previous=lightingRequests.get(button)||Promise.resolve(),next=previous.catch(()=>{}).then(async()=>{const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");button.classList.add("pressed");if(status)status.textContent=`Triggering ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"toggle",...triggerScope(button)})});if(status)status.textContent=`Triggered ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;throw error}finally{button.classList.remove("pressed")}});lightingRequests.set(button,next);next.catch(error=>console.error(error));return next} From 38dd175b3b0f514d5e279d70193d7a4ccba2a9c7 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 15:54:50 -0400 Subject: [PATCH 14/23] Always press selected lighting scene --- app/static/display.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/static/display.js b/app/static/display.js index a4dbbb3..f8db56e 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -21,8 +21,8 @@ async function hydrateLightingControls(){ try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} } const lightingRequests=new WeakMap(); -function toggleLightingScene(button){const previous=lightingRequests.get(button)||Promise.resolve(),next=previous.catch(()=>{}).then(async()=>{const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");button.classList.add("pressed");if(status)status.textContent=`Triggering ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"toggle",...triggerScope(button)})});if(status)status.textContent=`Triggered ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;throw error}finally{button.classList.remove("pressed")}});lightingRequests.set(button,next);next.catch(error=>console.error(error));return next} -document.addEventListener("click",event=>{const button=event.target.closest("[data-lighting-button]");if(!button||button.disabled)return;toggleLightingScene(button)}); +function triggerLightingScene(button){const previous=lightingRequests.get(button)||Promise.resolve(),next=previous.catch(()=>{}).then(async()=>{const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");button.classList.add("pressed");if(status)status.textContent=`Triggering ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"press",...triggerScope(button)})});if(status)status.textContent=`Triggered ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;throw error}finally{button.classList.remove("pressed")}});lightingRequests.set(button,next);next.catch(error=>console.error(error));return next} +document.addEventListener("click",event=>{const button=event.target.closest("[data-lighting-button]");if(!button||button.disabled)return;triggerLightingScene(button)}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppTrigger),playlistIndex=Number(button.dataset.ppPlaylistIndex);if(!Number.isInteger(index)||index<0||!Number.isInteger(playlistIndex)||playlistIndex<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-slide",{method:"POST",body:JSON.stringify({index,playlist_index:playlistIndex,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})});await refresh(true)}catch(error){alert(error.message)}finally{button.disabled=false}}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-playlist-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppPlaylistTrigger);if(!Number.isInteger(index)||index<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-playlist-item",{method:"POST",body:JSON.stringify({index,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})})}catch(error){alert(error.message)}finally{button.disabled=false}}); const keyboardStorageKey=widgetId=>`churchboard:${slug}:propresenter-keyboard:${widgetId}`; From 8b84bda4302e0e3801598ddf504d4886404c717d Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 16:02:50 -0400 Subject: [PATCH 15/23] Offset numbered lighting scene commands --- app/static/display.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/static/display.js b/app/static/display.js index f8db56e..7d6aa0c 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -16,9 +16,12 @@ function queueDashboardFit(){cancelAnimationFrame(dashboardFitFrame);dashboardFi function updateNativeSpl(){const osm=lastState.osm||{};document.querySelectorAll("[data-spl-meter]").forEach(meter=>{const value=Number(osm[meter.dataset.osmKey||"a_fast"]),green=Number(meter.dataset.green),orange=Number(meter.dataset.orange),reading=meter.querySelector("[data-spl-value]"),status=meter.querySelector("[data-spl-status]");if(!osm.connected||!Number.isFinite(value)){if(reading)reading.textContent="--";if(status)status.textContent="Waiting for Open Sound Meter";meter.classList.remove("spl-green","spl-orange","spl-red");return}if(reading)reading.textContent=value.toFixed(1);meter.classList.toggle("spl-green",value<=green);meter.classList.toggle("spl-orange",value>green&&value<=orange);meter.classList.toggle("spl-red",value>orange);if(status)status.textContent=`${osm.source_name||"OSM source"} · ${meter.dataset.osmLabel||"level"}`})} const triggerScope=element=>({dashboard_slug:slug,widget_id:element.closest(".widget")?.dataset.widget||null}); let lightingButtonsCache=null,lightingButtonsLoadedAt=0; +// Some TLC releases expose scene labels one step ahead of the command name. +// Keep the visible label unchanged, but send the preceding numbered scene. +const lightingCommandName=name=>String(name).replace(/(\d+)(?!.*\d)/,digits=>String(Math.max(1,Number(digits)-1))); async function hydrateLightingControls(){ const roots=[...document.querySelectorAll("[data-lighting-controls]")];if(!roots.length)return; - try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} + try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} } const lightingRequests=new WeakMap(); function triggerLightingScene(button){const previous=lightingRequests.get(button)||Promise.resolve(),next=previous.catch(()=>{}).then(async()=>{const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");button.classList.add("pressed");if(status)status.textContent=`Triggering ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"press",...triggerScope(button)})});if(status)status.textContent=`Triggered ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;throw error}finally{button.classList.remove("pressed")}});lightingRequests.set(button,next);next.catch(error=>console.error(error));return next} From 7a5b64df64456535f3ada2088012a6d0c743ee98 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 16:17:24 -0400 Subject: [PATCH 16/23] Send zero-based TLC scene commands --- app/services/thelightingcontroller.py | 7 +++++++ app/static/display.js | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/services/thelightingcontroller.py b/app/services/thelightingcontroller.py index 54f2460..88427c2 100644 --- a/app/services/thelightingcontroller.py +++ b/app/services/thelightingcontroller.py @@ -34,6 +34,13 @@ async def trigger_button(self, name: str, mode: str = "toggle") -> None: raise ValueError("Lighting button mode must be press, release, or toggle") reader, writer = await self._connect() try: + if mode == "press": + # A cue click is an unambiguous press. Do not require the + # command name to appear in BUTTON_LIST: some TLC versions + # expose one-based captions while accepting zero-based cue + # identifiers (for example, displayed "1" accepts "0"). + await self._send(writer, "BUTTON_PRESS", name) + return # Keep discovery and the command in the same authenticated session. # TLC installations commonly accept only one External App client. buttons = await self._button_list(reader, writer) diff --git a/app/static/display.js b/app/static/display.js index 7d6aa0c..05a9017 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -18,7 +18,7 @@ const triggerScope=element=>({dashboard_slug:slug,widget_id:element.closest(".wi let lightingButtonsCache=null,lightingButtonsLoadedAt=0; // Some TLC releases expose scene labels one step ahead of the command name. // Keep the visible label unchanged, but send the preceding numbered scene. -const lightingCommandName=name=>String(name).replace(/(\d+)(?!.*\d)/,digits=>String(Math.max(1,Number(digits)-1))); +const lightingCommandName=name=>String(name).replace(/(\d+)(?!.*\d)/,digits=>String(Number(digits)-1)); async function hydrateLightingControls(){ const roots=[...document.querySelectorAll("[data-lighting-controls]")];if(!roots.length)return; try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} From 2ca96e76d82df9bf0d0e28aee22cf9e10ce6673f Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 16:25:56 -0400 Subject: [PATCH 17/23] Preserve lighting widget scroll position --- app/static/display.js | 2 +- app/static/style.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/static/display.js b/app/static/display.js index 05a9017..076a37d 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -21,7 +21,7 @@ let lightingButtonsCache=null,lightingButtonsLoadedAt=0; const lightingCommandName=name=>String(name).replace(/(\d+)(?!.*\d)/,digits=>String(Number(digits)-1)); async function hydrateLightingControls(){ const roots=[...document.querySelectorAll("[data-lighting-controls]")];if(!roots.length)return; - try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
')})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} + try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const key=JSON.stringify(lightingButtonsCache),pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{if(root.dataset.lightingKey===key)return;const scrollTop=root.scrollTop;root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
');root.dataset.lightingKey=key;root.scrollTop=scrollTop})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} } const lightingRequests=new WeakMap(); function triggerLightingScene(button){const previous=lightingRequests.get(button)||Promise.resolve(),next=previous.catch(()=>{}).then(async()=>{const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");button.classList.add("pressed");if(status)status.textContent=`Triggering ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"press",...triggerScope(button)})});if(status)status.textContent=`Triggered ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;throw error}finally{button.classList.remove("pressed")}});lightingRequests.set(button,next);next.catch(error=>console.error(error));return next} diff --git a/app/static/style.css b/app/static/style.css index dd9fdeb..05ab75e 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -61,7 +61,7 @@ .dashboard .order-list li.active{background:linear-gradient(90deg,color-mix(in srgb,var(--board-effect) 18%,transparent),var(--glass-inner) 58%,color-mix(in srgb,var(--board-effect) 5%,transparent))} .dashboard .meter-track{background:color-mix(in srgb,var(--board-color) 54%,rgba(2,6,12,.72));box-shadow:inset 0 1px 2px #0008} .dashboard .control-buttons .take-control{background:linear-gradient(145deg,color-mix(in srgb,var(--board-effect) 18%,transparent),transparent 48%),linear-gradient(155deg,var(--glass-inner),var(--glass-inner-deep));border-color:color-mix(in srgb,var(--board-effect) 60%,var(--glass-inner-line))} -.lighting-controls{height:100%;overflow:auto;display:grid;align-content:start;gap:clamp(8px,2cqh,16px);padding:2px}.lighting-page{display:grid;gap:clamp(4px,1cqh,8px)}.lighting-page>strong{font-size:var(--lighting-page-size,18px);line-height:1.1}.lighting-page .control-buttons{grid-template-columns:repeat(var(--lighting-columns,3),minmax(min(100%,var(--lighting-scene-size,58px)),1fr));grid-auto-rows:var(--lighting-scene-size,58px);gap:clamp(4px,1cqw,8px)}.lighting-page .control-buttons button{min-height:var(--lighting-scene-size,58px);white-space:normal;overflow-wrap:anywhere;font-size:clamp(.56rem,2.4cqw,.9rem)}.lighting-status{min-height:1.2em;color:var(--muted);font-size:clamp(.52rem,2cqw,.72rem);text-align:center}.lighting-status:not(:empty){color:var(--accent)} +.lighting-controls{height:100%;overflow:auto;overscroll-behavior:contain;touch-action:pan-y;scrollbar-width:none;-ms-overflow-style:none;display:grid;align-content:start;gap:clamp(8px,2cqh,16px);padding:2px}.lighting-controls::-webkit-scrollbar{display:none}.lighting-page{display:grid;gap:clamp(4px,1cqh,8px)}.lighting-page>strong{font-size:var(--lighting-page-size,18px);line-height:1.1}.lighting-page .control-buttons{grid-template-columns:repeat(var(--lighting-columns,3),minmax(min(100%,var(--lighting-scene-size,58px)),1fr));grid-auto-rows:var(--lighting-scene-size,58px);gap:clamp(4px,1cqw,8px)}.lighting-page .control-buttons button{min-height:var(--lighting-scene-size,58px);white-space:normal;overflow-wrap:anywhere;font-size:clamp(.56rem,2.4cqw,.9rem)}.lighting-status{min-height:1.2em;color:var(--muted);font-size:clamp(.52rem,2cqw,.72rem);text-align:center}.lighting-status:not(:empty){color:var(--accent)} .lighting-page .control-buttons button.pressed{filter:brightness(.72);transform:translateY(1px);box-shadow:inset 0 2px 6px #0008} .dashboard .talent-photo-placeholder .unassigned-board-icon{display:block;width:clamp(62px,48%,154px);min-width:0;min-height:0;aspect-ratio:1;padding:0;border:0;border-radius:0;background:var(--board-effect);box-shadow:none;opacity:.82;-webkit-mask:url("/static/churchboard-mark.svg") center/contain no-repeat;mask:url("/static/churchboard-mark.svg") center/contain no-repeat;filter:drop-shadow(0 9px 16px #0008)} .dashboard .order-list li:not(.active){opacity:.58} From 02c6e1892bcb09a042804d24ad6666d1b355c5ee Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 16:36:26 -0400 Subject: [PATCH 18/23] Highlight active lighting scene --- app/static/display.js | 2 +- app/static/style.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/static/display.js b/app/static/display.js index 076a37d..6600457 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -21,7 +21,7 @@ let lightingButtonsCache=null,lightingButtonsLoadedAt=0; const lightingCommandName=name=>String(name).replace(/(\d+)(?!.*\d)/,digits=>String(Number(digits)-1)); async function hydrateLightingControls(){ const roots=[...document.querySelectorAll("[data-lighting-controls]")];if(!roots.length)return; - try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const key=JSON.stringify(lightingButtonsCache),pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{if(root.dataset.lightingKey===key)return;const scrollTop=root.scrollTop;root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
');root.dataset.lightingKey=key;root.scrollTop=scrollTop})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} + try{if(!lightingButtonsCache||Date.now()-lightingButtonsLoadedAt>5000){const result=await api("/api/integrations/lighting/buttons");lightingButtonsCache=result.items||[];lightingButtonsLoadedAt=Date.now()}const key=JSON.stringify(lightingButtonsCache),pages=new Map();lightingButtonsCache.forEach(button=>{const list=pages.get(button.page)||[];list.push(button);pages.set(button.page,list)});roots.forEach(root=>{if(root.dataset.lightingKey===key)return;const scrollTop=root.scrollTop;root.innerHTML=lightingButtonsCache.length?[...pages].map(([page,buttons])=>{const columns=Math.max(1,...buttons.map(button=>Number(button.page_columns)||1));return`
${escapeHtml(page)}
${buttons.map(button=>``).join("")}
`}).join(""):`
No pages are exposed to external applications
`;root.insertAdjacentHTML("beforeend",'
');root.dataset.lightingKey=key;root.scrollTop=scrollTop})}catch(error){roots.forEach(root=>root.innerHTML=`
${escapeHtml(error.message)}
`)} } const lightingRequests=new WeakMap(); function triggerLightingScene(button){const previous=lightingRequests.get(button)||Promise.resolve(),next=previous.catch(()=>{}).then(async()=>{const status=button.closest("[data-lighting-controls]")?.querySelector("[data-lighting-status]");button.classList.add("pressed");if(status)status.textContent=`Triggering ${button.dataset.lightingButton}…`;try{await api("/api/integrations/lighting/button",{method:"POST",body:JSON.stringify({name:button.dataset.lightingButton,mode:"press",...triggerScope(button)})});if(status)status.textContent=`Triggered ${button.dataset.lightingButton}`;lightingButtonsCache=null}catch(error){if(status)status.textContent=error.message;throw error}finally{button.classList.remove("pressed")}});lightingRequests.set(button,next);next.catch(error=>console.error(error));return next} diff --git a/app/static/style.css b/app/static/style.css index 05ab75e..4c146b5 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -62,7 +62,7 @@ .dashboard .meter-track{background:color-mix(in srgb,var(--board-color) 54%,rgba(2,6,12,.72));box-shadow:inset 0 1px 2px #0008} .dashboard .control-buttons .take-control{background:linear-gradient(145deg,color-mix(in srgb,var(--board-effect) 18%,transparent),transparent 48%),linear-gradient(155deg,var(--glass-inner),var(--glass-inner-deep));border-color:color-mix(in srgb,var(--board-effect) 60%,var(--glass-inner-line))} .lighting-controls{height:100%;overflow:auto;overscroll-behavior:contain;touch-action:pan-y;scrollbar-width:none;-ms-overflow-style:none;display:grid;align-content:start;gap:clamp(8px,2cqh,16px);padding:2px}.lighting-controls::-webkit-scrollbar{display:none}.lighting-page{display:grid;gap:clamp(4px,1cqh,8px)}.lighting-page>strong{font-size:var(--lighting-page-size,18px);line-height:1.1}.lighting-page .control-buttons{grid-template-columns:repeat(var(--lighting-columns,3),minmax(min(100%,var(--lighting-scene-size,58px)),1fr));grid-auto-rows:var(--lighting-scene-size,58px);gap:clamp(4px,1cqw,8px)}.lighting-page .control-buttons button{min-height:var(--lighting-scene-size,58px);white-space:normal;overflow-wrap:anywhere;font-size:clamp(.56rem,2.4cqw,.9rem)}.lighting-status{min-height:1.2em;color:var(--muted);font-size:clamp(.52rem,2cqw,.72rem);text-align:center}.lighting-status:not(:empty){color:var(--accent)} -.lighting-page .control-buttons button.pressed{filter:brightness(.72);transform:translateY(1px);box-shadow:inset 0 2px 6px #0008} +.lighting-page .control-buttons button.active{border:3px solid var(--accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 28%,transparent),inset 0 0 18px #ffffff22}.lighting-page .control-buttons button.pressed{filter:brightness(.72);transform:translateY(1px);box-shadow:inset 0 2px 6px #0008} .dashboard .talent-photo-placeholder .unassigned-board-icon{display:block;width:clamp(62px,48%,154px);min-width:0;min-height:0;aspect-ratio:1;padding:0;border:0;border-radius:0;background:var(--board-effect);box-shadow:none;opacity:.82;-webkit-mask:url("/static/churchboard-mark.svg") center/contain no-repeat;mask:url("/static/churchboard-mark.svg") center/contain no-repeat;filter:drop-shadow(0 9px 16px #0008)} .dashboard .order-list li:not(.active){opacity:.58} .dashboard .order-list li.active{color:#fff;border-left:3px solid #fff;background:linear-gradient(90deg,#ffffff2e,var(--glass-inner) 62%,#ffffff0d);box-shadow:inset 0 1px 0 #ffffff80,inset 0 0 20px #ffffff17,0 0 12px #ffffff0c} From 8de608c3d0e62eb3ca1fad4643f5a19f653ffad0 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 16:43:17 -0400 Subject: [PATCH 19/23] Customize active lighting border --- app/static/common.js | 2 +- app/static/editor.js | 8 ++++---- app/static/style.css | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/static/common.js b/app/static/common.js index 1143c6a..63ed4d5 100644 --- a/app/static/common.js +++ b/app/static/common.js @@ -162,7 +162,7 @@ const widgetMarkup = (widget, state) => { if(widget.type==="people") { const people=filteredPeople(settings,state);content=people.length?`
${people.map(person=>`
${person.photo?``:`${initials(person.name)}`}
${escapeHtml(person.name||"Unassigned")}${escapeHtml([person.position,person.team_name].filter(Boolean).join(" · "))}
`).join("")}
`:`
No scheduled people match these filters
`; } if(widget.type==="spl") { const green=Number(settings.green_max??75),orange=Number(settings.orange_max??85),weighting=["A","B","C","Z"].includes(settings.weighting)?settings.weighting:"A",response=settings.response==="Slow"?"Slow":"Fast",metricKey=`${weighting.toLowerCase()}_${response.toLowerCase()}`,metricLabel=`${weighting}-weighted ${response}`,osm=state.osm||{},value=Number(osm[metricKey]),reports=osm.reports_enabled!==false&&settings.reports_enabled!==false&&service.id?``:"";content=`
${Number.isFinite(value)?value.toFixed(1):"--"}dB
${metricLabel} · Green ≤ ${green}Orange ≤ ${orange}Red > ${orange}
${osm.connected?"Open Sound Meter connected":"Waiting for Open Sound Meter"}
${reports}
`; } if(widget.type==="controls") { const control=state.service_control||{},pcLive=state.planning_center_live||{},item=timing.current_item||{},isControlling=pcLive.enabled?!!pcLive.has_control:!!control.active,controlLabel=pcLive.enabled?"ProPresenter → Services LIVE":control.active?"Local control":"Following schedule",statusMessage=pcLive.enabled?pcLive.message||"":"";content=`
${escapeHtml(controlLabel)}${escapeHtml(item.title||"No current item")}
${escapeHtml(statusMessage)}
`; } - if(widget.type==="lighting") content=`
Loading exposed lighting controls…
`; + if(widget.type==="lighting") content=`
Loading exposed lighting controls…
`; if(widget.type==="person") { const person=(state.people||[]).find(p=>p.position===settings.position); content=person?`
${person.photo?``:initials(person.name)}
${escapeHtml(person.name)}
${escapeHtml(person.position)}
`:`
Choose a Planning Center position in the editor
`; } if(widget.type==="text") content=`
${escapeHtml(settings.text||"Custom text")}
`; if(widget.type==="restream") { const stream=state.restream||{},destinations=stream.destinations||[],status=String(stream.status||"offline").replace("-"," "),duration=formatDuration(stream.duration_seconds||0).replace(/^−/,"");content=stream.connected?`
${escapeHtml(status)}${escapeHtml(stream.title||"No active broadcast")}
${stream.viewers??"—"} viewers${duration} live${stream.bitrate_kbps?`${Math.round(stream.bitrate_kbps)} kbps`:"—"} bitrate
${destinations.length?destinations.map(destination=>`
${escapeHtml(destination.name)}${escapeHtml(destination.status||"offline")}
`).join(""):`
No Restream destinations found
`}
`:`
${escapeHtml(stream.error||"Connect Restream in Setup to monitor livestreams")}
`; } diff --git a/app/static/editor.js b/app/static/editor.js index 116ed4a..0df94e6 100644 --- a/app/static/editor.js +++ b/app/static/editor.js @@ -1,12 +1,12 @@ const slug=decodeURIComponent(location.pathname.split("/").pop()),grid=document.querySelector("#editor-grid"),form=document.querySelector("#inspector"); document.querySelector("#order-controls").insertAdjacentHTML("afterbegin",'

Choose a scrollable complete order, or fit every Planning Center item on the board without scrolling.

'); document.querySelector("#slides-controls").insertAdjacentHTML("afterend",''); -document.querySelector("#playlist-controls").insertAdjacentHTML("afterend",''); +document.querySelector("#playlist-controls").insertAdjacentHTML("afterend",''); document.querySelector("#assignment-mode-label").insertAdjacentHTML("afterend",'

One card per person combines all selected roles for the same person. One card per position repeats a person when they serve in more than one selected role.

'); document.querySelector("[name=order_limit]").closest("label").id="order-limit-label"; let dashboard,selected=null,dirty=false,catalogTeams=[],catalogError=""; const newId=type=>`${type}-${Date.now().toString(36)}`; -const defaults={clock:{w:3,h:2},service:{w:5,h:2},timing:{w:4,h:2},assignments:{w:7,h:6,team_ids:[],position_keys:[],position_labels:{},display_mode:"photos",card_grouping:"person",use_planning_center_icon:false,unassigned_media_title:"Icon"},slides:{w:6,h:4,show_notes:true,slide_mode:"image",slide_layout:"full",show_current:true,show_next:true,show_parts:true,show_slide_count:false},playlist:{w:6,h:7,allow_remote_trigger:true,keyboard_control:false,slide_size:120,item_size:48,marker_size:10,active_border_color:"#f5c400"},notes:{w:4,h:2},order:{w:5,h:3,display_mode:"current",limit:6,show_leader:false,show_mic:false},people:{w:4,h:4,team_ids:[],position_keys:[],position_labels:{}},spl:{w:4,h:3,green_max:75,orange_max:85,weighting:"A",response:"Fast"},controls:{w:4,h:2},lighting:{w:6,h:4,allow_remote_trigger:true,page_size:18,scene_size:58},restream:{w:5,h:4},obs:{w:5,h:4},propresenter_timers:{w:5,h:3},text:{w:4,h:2,text:"Custom text"}}; +const defaults={clock:{w:3,h:2},service:{w:5,h:2},timing:{w:4,h:2},assignments:{w:7,h:6,team_ids:[],position_keys:[],position_labels:{},display_mode:"photos",card_grouping:"person",use_planning_center_icon:false,unassigned_media_title:"Icon"},slides:{w:6,h:4,show_notes:true,slide_mode:"image",slide_layout:"full",show_current:true,show_next:true,show_parts:true,show_slide_count:false},playlist:{w:6,h:7,allow_remote_trigger:true,keyboard_control:false,slide_size:120,item_size:48,marker_size:10,active_border_color:"#f5c400"},notes:{w:4,h:2},order:{w:5,h:3,display_mode:"current",limit:6,show_leader:false,show_mic:false},people:{w:4,h:4,team_ids:[],position_keys:[],position_labels:{}},spl:{w:4,h:3,green_max:75,orange_max:85,weighting:"A",response:"Fast"},controls:{w:4,h:2},lighting:{w:6,h:4,allow_remote_trigger:true,page_size:18,scene_size:58,active_border_color:"#55e6a5",active_border_width:5},restream:{w:5,h:4},obs:{w:5,h:4},propresenter_timers:{w:5,h:3},text:{w:4,h:2,text:"Custom text"}}; const paletteTypes=["clock","service","timing","assignments","slides","playlist","notes","order","people","spl","controls","lighting","restream","obs","propresenter_timers","text"]; async function load(){ @@ -29,7 +29,7 @@ function select(id){ fields.use_planning_center_icon.checked=!!widget.settings.use_planning_center_icon;fields.unassigned_media_title.value=widget.settings.unassigned_media_title||"Icon";fields.unassigned_media_title.disabled=!fields.use_planning_center_icon.checked; fields.slide_layout.value=widget.settings.slide_layout==="previews_only"?"previews_only":"full";fields.slide_mode.value=widget.settings.slide_mode||"image";const showCurrent=widget.settings.show_current!==false,showNext=widget.settings.show_next!==false;fields.slide_visibility.value=showCurrent&&showNext?"both":showCurrent?"current":showNext?"next":"none";fields.show_parts.checked=widget.settings.show_parts!==false;fields.show_slide_count.checked=!!widget.settings.show_slide_count;fields.show_notes.checked=widget.settings.show_notes!==false; fields.playlist_slide_size.value=Math.max(80,Math.min(320,Number(widget.settings.slide_size)||120));fields.playlist_item_size.value=Math.max(40,Math.min(120,Number(widget.settings.item_size)||48));fields.playlist_marker_size.value=Math.max(8,Math.min(24,Number(widget.settings.marker_size)||10));fields.playlist_active_border_color.value=/^#[0-9a-f]{6}$/i.test(widget.settings.active_border_color||"")?widget.settings.active_border_color:"#f5c400"; - fields.lighting_page_size.value=Math.max(12,Math.min(40,Number(widget.settings.page_size)||18));fields.lighting_scene_size.value=Math.max(34,Math.min(120,Number(widget.settings.scene_size)||58));document.querySelector("#lighting-widget-controls").hidden=widget.type!=="lighting"; + fields.lighting_page_size.value=Math.max(12,Math.min(40,Number(widget.settings.page_size)||18));fields.lighting_scene_size.value=Math.max(34,Math.min(120,Number(widget.settings.scene_size)||58));fields.lighting_active_border_color.value=/^#[0-9a-f]{6}$/i.test(widget.settings.active_border_color||"")?widget.settings.active_border_color:"#55e6a5";fields.lighting_active_border_width.value=Math.max(2,Math.min(12,Number(widget.settings.active_border_width)||5));document.querySelector("#lighting-widget-controls").hidden=widget.type!=="lighting"; fields.order_display_mode.value=["full","fit"].includes(widget.settings.display_mode)?widget.settings.display_mode:"current";fields.order_limit.min=1;fields.order_limit.value=Math.max(1,Number(widget.settings.limit)||6);fields.show_leader.checked=!!widget.settings.show_leader;fields.show_mic.checked=!!widget.settings.show_mic; fields.spl_green.value=Number(widget.settings.green_max??75);fields.spl_orange.value=Number(widget.settings.orange_max??85);fields.spl_weighting.value=["A","B","C","Z"].includes(widget.settings.weighting)?widget.settings.weighting:"A";fields.spl_response.value=widget.settings.response==="Slow"?"Slow":"Fast"; document.querySelector("#assignment-controls").hidden=!isPositionWidget;document.querySelector("#assignment-mode-label").style.display=isAssignments?"grid":"none";document.querySelector("#unassigned-icon-controls").hidden=!isAssignments;document.querySelector("#slides-controls").hidden=widget.type!=="slides";document.querySelector("#playlist-controls").hidden=widget.type!=="playlist";document.querySelector("#order-controls").hidden=widget.type!=="order";document.querySelector("#order-limit-label").hidden=["full","fit"].includes(widget.settings.display_mode);document.querySelector("#spl-controls").hidden=widget.type!=="spl";fields.text.closest("label").style.display=widget.type==="text"?"grid":"none"; @@ -54,7 +54,7 @@ function beginPointer(event,widget,resizing){ paletteTypes.forEach(type=>{const name=widgetNames[type],button=document.createElement("button");button.className="palette-button";button.textContent=`+ ${name}`;button.onclick=()=>{const definition=defaults[type],bottom=Math.max(0,...dashboard.widgets.map(widget=>widget.y+widget.h));dashboard.widgets.push({id:newId(type),type,x:0,y:bottom,w:definition.w,h:definition.h,title:name,settings:Object.fromEntries(Object.entries(definition).filter(([key])=>!["w","h"].includes(key)))});changed();select(dashboard.widgets.at(-1).id)};document.querySelector("#widget-palette").append(button)}); form.addEventListener("input",event=>{if(event.target.matches("[data-team-id],[data-position-key]"))return;const widget=find(selected),fields=form.elements;widget.title=fields.title.value;widget.settings.show_title=fields.show_title.checked;widget.w=Math.max(1,Math.min(dashboard.columns,Number(fields.w.value)||1));widget.h=Math.max(1,Number(fields.h.value)||1);if(widget.type==="text")widget.settings.text=fields.text.value;if(widget.type==="slides")Object.assign(widget.settings,{slide_layout:fields.slide_layout.value,slide_mode:fields.slide_mode.value,show_current:["both","current"].includes(fields.slide_visibility.value),show_next:["both","next"].includes(fields.slide_visibility.value),show_parts:fields.show_parts.checked,show_slide_count:fields.show_slide_count.checked,show_notes:fields.show_notes.checked});if(widget.type==="playlist")Object.assign(widget.settings,{slide_size:Math.max(80,Math.min(320,Number(fields.playlist_slide_size.value)||120)),item_size:Math.max(40,Math.min(120,Number(fields.playlist_item_size.value)||48)),marker_size:Math.max(8,Math.min(24,Number(fields.playlist_marker_size.value)||10))});if(widget.type==="order")Object.assign(widget.settings,{display_mode:["full","fit"].includes(fields.order_display_mode.value)?fields.order_display_mode.value:"current",limit:Math.max(1,Math.min(20,Number(fields.order_limit.value)||6)),show_leader:fields.show_leader.checked,show_mic:fields.show_mic.checked});if(widget.type==="spl")Object.assign(widget.settings,{green_max:Number(fields.spl_green.value)||75,orange_max:Number(fields.spl_orange.value)||85,weighting:fields.spl_weighting.value,response:fields.spl_response.value});if(["assignments","mics"].includes(widget.type)){Object.assign(widget.settings,{display_mode:fields.assignment_mode.value,card_grouping:fields.assignment_grouping.value==="position"?"position":"person",use_planning_center_icon:fields.use_planning_center_icon.checked,unassigned_media_title:fields.unassigned_media_title.value.trim()||"Icon"});fields.unassigned_media_title.disabled=!fields.use_planning_center_icon.checked}if(widget.type==="order")document.querySelector("#order-limit-label").hidden=["full","fit"].includes(widget.settings.display_mode);setSlideControlState(widget,fields);changed();render()}); document.querySelector("#playlist-controls").addEventListener("input",event=>{if(event.target.name!=="playlist_active_border_color")return;const widget=find(selected);if(!widget||widget.type!=="playlist")return;widget.settings.active_border_color=event.target.value;changed();render()}); -document.querySelector("#lighting-widget-controls").addEventListener("input",()=>{const widget=find(selected);if(!widget||widget.type!=="lighting")return;widget.settings.page_size=Math.max(12,Math.min(40,Number(form.elements.lighting_page_size.value)||18));widget.settings.scene_size=Math.max(34,Math.min(120,Number(form.elements.lighting_scene_size.value)||58));changed();render()}); +document.querySelector("#lighting-widget-controls").addEventListener("input",()=>{const widget=find(selected);if(!widget||widget.type!=="lighting")return;widget.settings.page_size=Math.max(12,Math.min(40,Number(form.elements.lighting_page_size.value)||18));widget.settings.scene_size=Math.max(34,Math.min(120,Number(form.elements.lighting_scene_size.value)||58));widget.settings.active_border_color=form.elements.lighting_active_border_color.value;widget.settings.active_border_width=Math.max(2,Math.min(12,Number(form.elements.lighting_active_border_width.value)||5));changed();render()}); document.querySelector("#assignment-controls").addEventListener("change",event=>{ const widget=find(selected);if(!widget)return; if(event.target.matches("[data-team-id]")){widget.settings.team_ids=[...document.querySelectorAll("[data-team-id]:checked")].map(input=>input.dataset.teamId);const visible=new Set((widget.settings.team_ids.length?catalogTeams.filter(team=>widget.settings.team_ids.includes(String(team.id))):catalogTeams).flatMap(team=>team.positions.map(position=>position.key)));widget.settings.position_keys=(widget.settings.position_keys||[]).filter(key=>visible.has(key))} diff --git a/app/static/style.css b/app/static/style.css index 4c146b5..ed6b087 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -62,7 +62,7 @@ .dashboard .meter-track{background:color-mix(in srgb,var(--board-color) 54%,rgba(2,6,12,.72));box-shadow:inset 0 1px 2px #0008} .dashboard .control-buttons .take-control{background:linear-gradient(145deg,color-mix(in srgb,var(--board-effect) 18%,transparent),transparent 48%),linear-gradient(155deg,var(--glass-inner),var(--glass-inner-deep));border-color:color-mix(in srgb,var(--board-effect) 60%,var(--glass-inner-line))} .lighting-controls{height:100%;overflow:auto;overscroll-behavior:contain;touch-action:pan-y;scrollbar-width:none;-ms-overflow-style:none;display:grid;align-content:start;gap:clamp(8px,2cqh,16px);padding:2px}.lighting-controls::-webkit-scrollbar{display:none}.lighting-page{display:grid;gap:clamp(4px,1cqh,8px)}.lighting-page>strong{font-size:var(--lighting-page-size,18px);line-height:1.1}.lighting-page .control-buttons{grid-template-columns:repeat(var(--lighting-columns,3),minmax(min(100%,var(--lighting-scene-size,58px)),1fr));grid-auto-rows:var(--lighting-scene-size,58px);gap:clamp(4px,1cqw,8px)}.lighting-page .control-buttons button{min-height:var(--lighting-scene-size,58px);white-space:normal;overflow-wrap:anywhere;font-size:clamp(.56rem,2.4cqw,.9rem)}.lighting-status{min-height:1.2em;color:var(--muted);font-size:clamp(.52rem,2cqw,.72rem);text-align:center}.lighting-status:not(:empty){color:var(--accent)} -.lighting-page .control-buttons button.active{border:3px solid var(--accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 28%,transparent),inset 0 0 18px #ffffff22}.lighting-page .control-buttons button.pressed{filter:brightness(.72);transform:translateY(1px);box-shadow:inset 0 2px 6px #0008} +.lighting-page .control-buttons button.active{border:var(--lighting-active-border-width,5px) solid var(--lighting-active-border,var(--accent));box-shadow:0 0 0 2px color-mix(in srgb,var(--lighting-active-border,var(--accent)) 28%,transparent),inset 0 0 18px #ffffff22}.lighting-page .control-buttons button.pressed{filter:brightness(.72);transform:translateY(1px);box-shadow:inset 0 2px 6px #0008} .dashboard .talent-photo-placeholder .unassigned-board-icon{display:block;width:clamp(62px,48%,154px);min-width:0;min-height:0;aspect-ratio:1;padding:0;border:0;border-radius:0;background:var(--board-effect);box-shadow:none;opacity:.82;-webkit-mask:url("/static/churchboard-mark.svg") center/contain no-repeat;mask:url("/static/churchboard-mark.svg") center/contain no-repeat;filter:drop-shadow(0 9px 16px #0008)} .dashboard .order-list li:not(.active){opacity:.58} .dashboard .order-list li.active{color:#fff;border-left:3px solid #fff;background:linear-gradient(90deg,#ffffff2e,var(--glass-inner) 62%,#ffffff0d);box-shadow:inset 0 1px 0 #ffffff80,inset 0 0 20px #ffffff17,0 0 12px #ffffff0c} From b578c12d8088469ead309e43bc190da22b4713f0 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 16:53:43 -0400 Subject: [PATCH 20/23] Revert "Move ProPresenter controls to live widget and fix thumbnails" This reverts commit 682f0b2c1bb38cbf984812725956311eb10c9917. --- app/services/propresenter.py | 15 ++++++++------- app/static/common.js | 6 +++--- app/static/display.js | 10 ++-------- app/static/editor.js | 6 +++--- app/static/style.css | 2 +- docs/PROPRESENTER.md | 12 ++++++------ tests/test_api.py | 23 ++++------------------- tests/test_core.py | 34 ++-------------------------------- 8 files changed, 29 insertions(+), 79 deletions(-) diff --git a/app/services/propresenter.py b/app/services/propresenter.py index ae1b312..1615720 100644 --- a/app/services/propresenter.py +++ b/app/services/propresenter.py @@ -645,11 +645,14 @@ def arrangement_order(row: dict[str, Any]) -> int: if not sequence_ids: return cls._cue_entries(raw) + referenced = set(sequence_ids) + first_arranged = next((index for index, group in enumerate(groups) if identifier(group) in referenced), 0) entries: list[dict[str, Any]] = [] - # ProPresenter's thumbnail and presentation-index routes address the - # active arrangement itself. Library groups that are not referenced by - # that arrangement must not be inserted ahead of it, or every thumbnail - # and active-cue marker after that point is shifted. + # Thumbnail indexes include leading media/background cues even though + # presentation_index and the active arrangement do not. + for group in groups[:first_arranged]: + if isinstance(group, dict): + entries.extend(cls._cue_entries(group)) for group_id in sequence_ids: entries.extend(cls._cue_entries(group_map[group_id])) return entries @@ -797,10 +800,8 @@ async def thumbnail(self, presentation_uuid: str, index: int) -> tuple[bytes, st raise ValueError("Invalid ProPresenter presentation or slide index") base = f"http://{self.settings.get('host', '127.0.0.1')}:{int(self.settings.get('port', 50001))}" async with httpx.AsyncClient(timeout=5) as client: - # Presentation state and trigger routes use zero-based cue indexes, - # while ProPresenter's thumbnail route uses one-based cue numbers. response = await client.get( - f"{base}/v1/presentation/{quote(presentation_uuid, safe='')}/thumbnail/{index + 1}", + f"{base}/v1/presentation/{quote(presentation_uuid, safe='')}/thumbnail/{index}", params={"quality": 960, "thumbnail_type": "jpeg"}, headers={"Accept": "image/jpeg"}, ) diff --git a/app/static/common.js b/app/static/common.js index 63ed4d5..7a367b3 100644 --- a/app/static/common.js +++ b/app/static/common.js @@ -80,11 +80,11 @@ const presentationDisplayTitle = pp => { const propresenterPlaylistMarkup = (pp,allowTrigger=false,options={}) => { const slideSize=Math.max(80,Math.min(320,Number(options.slide_size)||120)),itemSize=Math.max(40,Math.min(120,Number(options.item_size)||48)),markerSize=Math.max(8,Math.min(24,Number(options.marker_size)||10)),activeBorderColor=safeCssColor(options.active_border_color||"#f5c400"); const presentations=Array.isArray(pp.playlist_presentations)?pp.playlist_presentations:[]; - const operatorControls=`
`; - if(!presentations.length)return `
${operatorControls}
${escapeHtml(pp.connected===false?pp.error||"ProPresenter is disconnected":"Focus a playlist in ProPresenter to see its items")}
`; + const keyboardStatus=options.keyboard_control?'Keyboard: ←/↑ back · →/↓/Space next':""; + if(!presentations.length)return `
${keyboardStatus}
${escapeHtml(pp.connected===false?pp.error||"ProPresenter is disconnected":"Focus a playlist in ProPresenter to see its items")}
`; const slideMarkup=item=>{let previousPart="";return(Array.isArray(item.slides)?item.slides:[]).map(slide=>{const slideNumber=Number(slide.index),part=String(slide.part||"Unlabeled"),startsSection=part!==previousPart,isActive=String(item.presentation_uuid||"")===String(pp.presentation_uuid||"")&&slideNumber===Number(pp.current?.index);previousPart=part;const marker=startsSection?`${escapeHtml(part)}`:"";return``}).join("")}; const rows=presentations.map(item=>{const placeholder=item.type==="placeholder"||item.triggerable===false;if(placeholder)return`
${Number(item.index)+1}
${escapeHtml(item.title)}Planning Center placeholder · link content in ProPresenter
`;const itemSlides=slideMarkup(item),itemActive=String(item.presentation_uuid||"")===String(pp.presentation_uuid||"")||item.active;return`
${itemSlides?`
${itemSlides}
`:`
No slides returned by ProPresenter
`}
`}).join(""); - return `
${escapeHtml(pp.playlist_name||"ProPresenter Playlist")}${presentations.length} items
${escapeHtml(presentationDisplayTitle(pp))}${operatorControls}
${rows}
`; + return `
${escapeHtml(pp.playlist_name||"ProPresenter Playlist")}${presentations.length} items
${escapeHtml(presentationDisplayTitle(pp))}${keyboardStatus}
${rows}
`; }; const isOrderHeader = item => normalized(item?.item_type)==="header"; const visibleOrderItems = (items,currentId,requestedLimit) => { diff --git a/app/static/display.js b/app/static/display.js index 6600457..f870aa3 100644 --- a/app/static/display.js +++ b/app/static/display.js @@ -28,14 +28,8 @@ function triggerLightingScene(button){const previous=lightingRequests.get(button document.addEventListener("click",event=>{const button=event.target.closest("[data-lighting-button]");if(!button||button.disabled)return;triggerLightingScene(button)}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppTrigger),playlistIndex=Number(button.dataset.ppPlaylistIndex);if(!Number.isInteger(index)||index<0||!Number.isInteger(playlistIndex)||playlistIndex<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-slide",{method:"POST",body:JSON.stringify({index,playlist_index:playlistIndex,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})});await refresh(true)}catch(error){alert(error.message)}finally{button.disabled=false}}); document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-playlist-trigger]");if(!button||button.disabled)return;const index=Number(button.dataset.ppPlaylistTrigger);if(!Number.isInteger(index)||index<0)return;button.disabled=true;try{await api("/api/integrations/propresenter/active-playlist-item",{method:"POST",body:JSON.stringify({index,presentation_uuid:button.dataset.ppPresentationUuid||null,is_pco:button.dataset.ppIsPco==="true",...triggerScope(button)})})}catch(error){alert(error.message)}finally{button.disabled=false}}); -const keyboardStorageKey=widgetId=>`churchboard:${slug}:propresenter-keyboard:${widgetId}`; -function syncPlaylistOperatorToggles(root=document){root.querySelectorAll('[data-widget-type="playlist"]').forEach(element=>{const widgetId=element.dataset.widget,controls=element.querySelector("[data-pp-controls-toggle]"),keyboard=element.querySelector("[data-pp-keyboard-toggle]");if(!keyboard)return;const controlsEnabled=!!controls?.checked;keyboard.disabled=!controlsEnabled;keyboard.checked=controlsEnabled&&localStorage.getItem(keyboardStorageKey(widgetId))==="true"})} -function keyboardPlaylistWidget(){const toggle=document.querySelector('[data-widget-type="playlist"] [data-pp-keyboard-toggle]:checked:not(:disabled)'),widgetId=toggle?.closest(".widget")?.dataset.widget;return(dashboard?.widgets||[]).find(widget=>String(widget.id)===String(widgetId))} +function keyboardPlaylistWidget(){return(dashboard?.widgets||[]).find(widget=>widget.type==="playlist"&&widget.settings?.allow_remote_trigger!==false&&widget.settings?.keyboard_control===true)} function setPlaylistKeyboardStatus(message,isError=false){document.querySelectorAll("[data-pp-keyboard-status]").forEach(element=>{element.textContent=message;element.classList.toggle("error",isError)})} -document.addEventListener("change",async event=>{ - const keyboard=event.target.closest("[data-pp-keyboard-toggle]");if(keyboard){const widgetId=keyboard.closest(".widget")?.dataset.widget;if(widgetId)localStorage.setItem(keyboardStorageKey(widgetId),String(keyboard.checked));setPlaylistKeyboardStatus(keyboard.checked?"←/↑ back · →/↓/Space next":"");return} - const controls=event.target.closest("[data-pp-controls-toggle]");if(!controls)return;const widgetId=controls.closest(".widget")?.dataset.widget,widget=(dashboard?.widgets||[]).find(item=>String(item.id)===String(widgetId));if(!widget)return;controls.disabled=true;widget.settings={...(widget.settings||{}),allow_remote_trigger:controls.checked};if(!controls.checked)localStorage.setItem(keyboardStorageKey(widgetId),"false");try{dashboard=await api(`/api/dashboards/${encodeURIComponent(dashboard.id)}`,{method:"PUT",body:JSON.stringify(dashboard)});widgetRenderKeys.delete(String(widgetId));render()}catch(error){widget.settings.allow_remote_trigger=!controls.checked;controls.checked=!controls.checked;controls.disabled=false;alert(error.message)} -}); document.addEventListener("keydown",async event=>{ const playlistWidget=keyboardPlaylistWidget();if(!playlistWidget||ppKeyboardInFlight||event.defaultPrevented||event.repeat||event.metaKey||event.ctrlKey||event.altKey)return; const target=event.target;if(target instanceof Element&&(target.closest("input,textarea,select,button,a,[contenteditable=true]")||target.closest(".display-menu.open")))return; @@ -106,7 +100,7 @@ function render(){ for(const [id,element] of existing){if(!activeIds.has(id)){element.remove();widgetRenderKeys.delete(id);orderScrollPositions.delete(id);playlistScrollPositions.delete(id);changed=true}} if(!widgets.length&&root.innerHTML!==`
This dashboard has no widgets.
`){root.innerHTML=`
This dashboard has no widgets.
`;changed=true} updateTimingWidgets();updateOrderTimingWidgets(); - if(changed){tickClocks();enhanceDynamicContent(root);syncPlaylistOperatorToggles(root)} + if(changed){tickClocks();enhanceDynamicContent(root)} queueDashboardFit(); updateNativeSpl(); hydrateLightingControls(); diff --git a/app/static/editor.js b/app/static/editor.js index 0df94e6..7ed0085 100644 --- a/app/static/editor.js +++ b/app/static/editor.js @@ -1,6 +1,6 @@ const slug=decodeURIComponent(location.pathname.split("/").pop()),grid=document.querySelector("#editor-grid"),form=document.querySelector("#inspector"); document.querySelector("#order-controls").insertAdjacentHTML("afterbegin",'

Choose a scrollable complete order, or fit every Planning Center item on the board without scrolling.

'); -document.querySelector("#slides-controls").insertAdjacentHTML("afterend",''); +document.querySelector("#slides-controls").insertAdjacentHTML("afterend",''); document.querySelector("#playlist-controls").insertAdjacentHTML("afterend",''); document.querySelector("#assignment-mode-label").insertAdjacentHTML("afterend",'

One card per person combines all selected roles for the same person. One card per position repeats a person when they serve in more than one selected role.

'); document.querySelector("[name=order_limit]").closest("label").id="order-limit-label"; @@ -28,7 +28,7 @@ function select(id){ document.querySelector("#inspector-empty").hidden=true;form.hidden=false;fields.title.value=widget.title||"";fields.show_title.checked=widget.settings.show_title!==false;fields.w.value=widget.w;fields.h.value=widget.h;fields.text.value=widget.settings.text||"";fields.assignment_mode.value=widget.settings.display_mode||"photos";fields.assignment_grouping.value=widget.settings.card_grouping==="position"?"position":"person"; fields.use_planning_center_icon.checked=!!widget.settings.use_planning_center_icon;fields.unassigned_media_title.value=widget.settings.unassigned_media_title||"Icon";fields.unassigned_media_title.disabled=!fields.use_planning_center_icon.checked; fields.slide_layout.value=widget.settings.slide_layout==="previews_only"?"previews_only":"full";fields.slide_mode.value=widget.settings.slide_mode||"image";const showCurrent=widget.settings.show_current!==false,showNext=widget.settings.show_next!==false;fields.slide_visibility.value=showCurrent&&showNext?"both":showCurrent?"current":showNext?"next":"none";fields.show_parts.checked=widget.settings.show_parts!==false;fields.show_slide_count.checked=!!widget.settings.show_slide_count;fields.show_notes.checked=widget.settings.show_notes!==false; - fields.playlist_slide_size.value=Math.max(80,Math.min(320,Number(widget.settings.slide_size)||120));fields.playlist_item_size.value=Math.max(40,Math.min(120,Number(widget.settings.item_size)||48));fields.playlist_marker_size.value=Math.max(8,Math.min(24,Number(widget.settings.marker_size)||10));fields.playlist_active_border_color.value=/^#[0-9a-f]{6}$/i.test(widget.settings.active_border_color||"")?widget.settings.active_border_color:"#f5c400"; + fields.playlist_allow_remote_trigger.checked=widget.settings.allow_remote_trigger!==false;fields.playlist_keyboard_control.checked=!!widget.settings.keyboard_control;fields.playlist_keyboard_control.disabled=!fields.playlist_allow_remote_trigger.checked;fields.playlist_slide_size.value=Math.max(80,Math.min(320,Number(widget.settings.slide_size)||120));fields.playlist_item_size.value=Math.max(40,Math.min(120,Number(widget.settings.item_size)||48));fields.playlist_marker_size.value=Math.max(8,Math.min(24,Number(widget.settings.marker_size)||10));fields.playlist_active_border_color.value=/^#[0-9a-f]{6}$/i.test(widget.settings.active_border_color||"")?widget.settings.active_border_color:"#f5c400"; fields.lighting_page_size.value=Math.max(12,Math.min(40,Number(widget.settings.page_size)||18));fields.lighting_scene_size.value=Math.max(34,Math.min(120,Number(widget.settings.scene_size)||58));fields.lighting_active_border_color.value=/^#[0-9a-f]{6}$/i.test(widget.settings.active_border_color||"")?widget.settings.active_border_color:"#55e6a5";fields.lighting_active_border_width.value=Math.max(2,Math.min(12,Number(widget.settings.active_border_width)||5));document.querySelector("#lighting-widget-controls").hidden=widget.type!=="lighting"; fields.order_display_mode.value=["full","fit"].includes(widget.settings.display_mode)?widget.settings.display_mode:"current";fields.order_limit.min=1;fields.order_limit.value=Math.max(1,Number(widget.settings.limit)||6);fields.show_leader.checked=!!widget.settings.show_leader;fields.show_mic.checked=!!widget.settings.show_mic; fields.spl_green.value=Number(widget.settings.green_max??75);fields.spl_orange.value=Number(widget.settings.orange_max??85);fields.spl_weighting.value=["A","B","C","Z"].includes(widget.settings.weighting)?widget.settings.weighting:"A";fields.spl_response.value=widget.settings.response==="Slow"?"Slow":"Fast"; @@ -52,7 +52,7 @@ function beginPointer(event,widget,resizing){ const up=()=>{document.body.classList.remove("resizing-widget","moving-widget");window.removeEventListener("pointermove",move);window.removeEventListener("pointerup",up);select(widget.id)};window.addEventListener("pointermove",move);window.addEventListener("pointerup",up); } paletteTypes.forEach(type=>{const name=widgetNames[type],button=document.createElement("button");button.className="palette-button";button.textContent=`+ ${name}`;button.onclick=()=>{const definition=defaults[type],bottom=Math.max(0,...dashboard.widgets.map(widget=>widget.y+widget.h));dashboard.widgets.push({id:newId(type),type,x:0,y:bottom,w:definition.w,h:definition.h,title:name,settings:Object.fromEntries(Object.entries(definition).filter(([key])=>!["w","h"].includes(key)))});changed();select(dashboard.widgets.at(-1).id)};document.querySelector("#widget-palette").append(button)}); -form.addEventListener("input",event=>{if(event.target.matches("[data-team-id],[data-position-key]"))return;const widget=find(selected),fields=form.elements;widget.title=fields.title.value;widget.settings.show_title=fields.show_title.checked;widget.w=Math.max(1,Math.min(dashboard.columns,Number(fields.w.value)||1));widget.h=Math.max(1,Number(fields.h.value)||1);if(widget.type==="text")widget.settings.text=fields.text.value;if(widget.type==="slides")Object.assign(widget.settings,{slide_layout:fields.slide_layout.value,slide_mode:fields.slide_mode.value,show_current:["both","current"].includes(fields.slide_visibility.value),show_next:["both","next"].includes(fields.slide_visibility.value),show_parts:fields.show_parts.checked,show_slide_count:fields.show_slide_count.checked,show_notes:fields.show_notes.checked});if(widget.type==="playlist")Object.assign(widget.settings,{slide_size:Math.max(80,Math.min(320,Number(fields.playlist_slide_size.value)||120)),item_size:Math.max(40,Math.min(120,Number(fields.playlist_item_size.value)||48)),marker_size:Math.max(8,Math.min(24,Number(fields.playlist_marker_size.value)||10))});if(widget.type==="order")Object.assign(widget.settings,{display_mode:["full","fit"].includes(fields.order_display_mode.value)?fields.order_display_mode.value:"current",limit:Math.max(1,Math.min(20,Number(fields.order_limit.value)||6)),show_leader:fields.show_leader.checked,show_mic:fields.show_mic.checked});if(widget.type==="spl")Object.assign(widget.settings,{green_max:Number(fields.spl_green.value)||75,orange_max:Number(fields.spl_orange.value)||85,weighting:fields.spl_weighting.value,response:fields.spl_response.value});if(["assignments","mics"].includes(widget.type)){Object.assign(widget.settings,{display_mode:fields.assignment_mode.value,card_grouping:fields.assignment_grouping.value==="position"?"position":"person",use_planning_center_icon:fields.use_planning_center_icon.checked,unassigned_media_title:fields.unassigned_media_title.value.trim()||"Icon"});fields.unassigned_media_title.disabled=!fields.use_planning_center_icon.checked}if(widget.type==="order")document.querySelector("#order-limit-label").hidden=["full","fit"].includes(widget.settings.display_mode);setSlideControlState(widget,fields);changed();render()}); +form.addEventListener("input",event=>{if(event.target.matches("[data-team-id],[data-position-key]"))return;const widget=find(selected),fields=form.elements;widget.title=fields.title.value;widget.settings.show_title=fields.show_title.checked;widget.w=Math.max(1,Math.min(dashboard.columns,Number(fields.w.value)||1));widget.h=Math.max(1,Number(fields.h.value)||1);if(widget.type==="text")widget.settings.text=fields.text.value;if(widget.type==="slides")Object.assign(widget.settings,{slide_layout:fields.slide_layout.value,slide_mode:fields.slide_mode.value,show_current:["both","current"].includes(fields.slide_visibility.value),show_next:["both","next"].includes(fields.slide_visibility.value),show_parts:fields.show_parts.checked,show_slide_count:fields.show_slide_count.checked,show_notes:fields.show_notes.checked});if(widget.type==="playlist"){Object.assign(widget.settings,{allow_remote_trigger:fields.playlist_allow_remote_trigger.checked,keyboard_control:fields.playlist_allow_remote_trigger.checked&&fields.playlist_keyboard_control.checked,slide_size:Math.max(80,Math.min(320,Number(fields.playlist_slide_size.value)||120)),item_size:Math.max(40,Math.min(120,Number(fields.playlist_item_size.value)||48)),marker_size:Math.max(8,Math.min(24,Number(fields.playlist_marker_size.value)||10))});fields.playlist_keyboard_control.disabled=!fields.playlist_allow_remote_trigger.checked}if(widget.type==="order")Object.assign(widget.settings,{display_mode:["full","fit"].includes(fields.order_display_mode.value)?fields.order_display_mode.value:"current",limit:Math.max(1,Math.min(20,Number(fields.order_limit.value)||6)),show_leader:fields.show_leader.checked,show_mic:fields.show_mic.checked});if(widget.type==="spl")Object.assign(widget.settings,{green_max:Number(fields.spl_green.value)||75,orange_max:Number(fields.spl_orange.value)||85,weighting:fields.spl_weighting.value,response:fields.spl_response.value});if(["assignments","mics"].includes(widget.type)){Object.assign(widget.settings,{display_mode:fields.assignment_mode.value,card_grouping:fields.assignment_grouping.value==="position"?"position":"person",use_planning_center_icon:fields.use_planning_center_icon.checked,unassigned_media_title:fields.unassigned_media_title.value.trim()||"Icon"});fields.unassigned_media_title.disabled=!fields.use_planning_center_icon.checked}if(widget.type==="order")document.querySelector("#order-limit-label").hidden=["full","fit"].includes(widget.settings.display_mode);setSlideControlState(widget,fields);changed();render()}); document.querySelector("#playlist-controls").addEventListener("input",event=>{if(event.target.name!=="playlist_active_border_color")return;const widget=find(selected);if(!widget||widget.type!=="playlist")return;widget.settings.active_border_color=event.target.value;changed();render()}); document.querySelector("#lighting-widget-controls").addEventListener("input",()=>{const widget=find(selected);if(!widget||widget.type!=="lighting")return;widget.settings.page_size=Math.max(12,Math.min(40,Number(form.elements.lighting_page_size.value)||18));widget.settings.scene_size=Math.max(34,Math.min(120,Number(form.elements.lighting_scene_size.value)||58));widget.settings.active_border_color=form.elements.lighting_active_border_color.value;widget.settings.active_border_width=Math.max(2,Math.min(12,Number(form.elements.lighting_active_border_width.value)||5));changed();render()}); document.querySelector("#assignment-controls").addEventListener("change",event=>{ diff --git a/app/static/style.css b/app/static/style.css index ed6b087..342890c 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -115,7 +115,7 @@ .slide-video-status{position:absolute;z-index:4;right:clamp(5px,1.2cqw,10px);bottom:clamp(5px,1.2cqh,10px);left:clamp(5px,1.2cqw,10px);display:grid;gap:4px;padding:5px 8px;border:1px solid #ffffff30;border-radius:8px;background:#02050ac7;box-shadow:0 5px 18px #0009;color:#fff;backdrop-filter:blur(8px)}.slide-video-status>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:8px}.slide-video-status strong{font-size:clamp(.48rem,1.8cqw,.66rem);letter-spacing:.08em}.slide-video-status span{font-size:clamp(.46rem,1.7cqw,.64rem);font-variant-numeric:tabular-nums}.slide-video-track{height:3px;border-radius:99px;background:#ffffff2b;overflow:hidden}.slide-video-track i{display:block;height:100%;border-radius:inherit;background:#48ff9a;box-shadow:0 0 8px #48ff9a} .slide-canvas.has-video .slide-live-timer{top:42%}.widget.widget-slide-compact .slide-video-status{gap:2px;padding:3px 5px}.widget.widget-slide-compact .slide-video-track{height:2px} .pp-playlist{height:100%;min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px}.pp-playlist-title{display:flex;justify-content:space-between;gap:8px;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.pp-playlist-title span{color:var(--muted);font-size:.75rem;font-weight:700}.pp-slide-grid{overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));align-content:start;gap:8px;padding:1px}.pp-grid-slide{position:relative;display:grid;grid-template-rows:72px auto auto;gap:4px;min-width:0;padding:5px;border:1px solid var(--line);border-radius:8px;background:#080b10;color:var(--text);text-align:left;cursor:pointer}.theme-light .pp-grid-slide{background:#f5f7fa}.pp-grid-slide:disabled{cursor:default}.pp-grid-slide.active{border:2px solid var(--accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 26%,transparent)}.pp-grid-slide img{width:100%;height:72px;object-fit:cover;border-radius:5px;background:#000}.pp-grid-number{position:absolute;z-index:1;top:8px;left:8px;padding:2px 5px;border-radius:99px;background:#000c;color:#fff;font-size:.65rem;font-weight:850}.pp-grid-slide strong{font-size:.72rem;line-height:1.12;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.pp-grid-slide em{border-left:3px solid var(--part-color);padding-left:5px;color:var(--muted);font-size:.62rem;font-style:normal;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.pp-operator-toggles{display:flex!important;grid-auto-flow:column;align-items:center;justify-content:end;flex-wrap:wrap;gap:10px 16px!important}.pp-switch{display:inline-flex;align-items:center;gap:10px;color:var(--text);font-size:clamp(.7rem,1.75cqw,.94rem);font-weight:800;white-space:nowrap;cursor:pointer}.pp-switch input{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}.pp-switch-track{position:relative;flex:none;width:54px;height:30px;border:1px solid color-mix(in srgb,var(--muted) 55%,var(--line));border-radius:999px;background:color-mix(in srgb,var(--glass-inner-deep) 88%,#000);box-shadow:inset 0 2px 5px #0008;transition:background .16s ease,border-color .16s ease,box-shadow .16s ease}.pp-switch-track::after{content:"";position:absolute;top:3px;left:3px;width:22px;height:22px;border-radius:50%;background:#aab4c3;box-shadow:0 2px 5px #0008;transition:transform .16s ease,background .16s ease}.pp-switch input:checked+.pp-switch-track{border-color:color-mix(in srgb,var(--accent) 78%,white);background:color-mix(in srgb,var(--accent) 62%,#10251d);box-shadow:0 0 14px color-mix(in srgb,var(--accent) 30%,transparent),inset 0 1px 2px #ffffff35}.pp-switch input:checked+.pp-switch-track::after{transform:translateX(24px);background:#fff}.pp-switch input:focus-visible+.pp-switch-track{outline:3px solid color-mix(in srgb,var(--accent) 45%,transparent);outline-offset:2px}.pp-switch:has(input:disabled){opacity:.42;cursor:not-allowed}.pp-keyboard-status{color:var(--muted);font-size:clamp(.56rem,1.4cqw,.76rem);font-weight:700;white-space:nowrap}.pp-keyboard-status.error{color:var(--danger)}.pp-browser-empty{height:100%;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px}.pp-browser-empty>.pp-operator-toggles{justify-self:end} +.pp-keyboard-status{color:var(--muted);font-size:clamp(.48rem,1.2cqw,.68rem);font-weight:700;white-space:nowrap}.pp-keyboard-status.error{color:var(--danger)}.pp-browser-empty{height:100%;display:grid;grid-template-rows:auto minmax(0,1fr);gap:8px}.pp-browser-empty>.pp-keyboard-status{justify-self:end} .pp-presentation-section{min-height:0}.pp-section-label{display:flex;justify-content:space-between;margin-bottom:5px;font-size:.72rem;font-weight:800}.pp-section-label span{color:var(--muted)}.pp-presentation-grid{display:flex;gap:6px;overflow:auto;padding-bottom:2px}.pp-presentation{flex:0 0 150px;display:grid;grid-template-columns:auto 1fr;gap:7px;align-items:center;padding:7px;border:1px solid var(--line);border-radius:7px;background:#080b10;color:var(--text);text-align:left;cursor:pointer}.pp-presentation:disabled{cursor:default}.pp-presentation.active{border-color:var(--accent);box-shadow:inset 3px 0 var(--accent)}.pp-presentation span{color:var(--muted);font-size:.7rem;font-weight:800}.pp-presentation strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.72rem} .producer-layout{display:grid;grid-template-columns:minmax(190px,.9fr) minmax(250px,1.15fr) minmax(310px,1.35fr);height:100%;min-height:0;background:#0b1019}.producer-layout>section{min-width:0;min-height:0;padding:16px;border-right:1px solid #273142;overflow:auto}.producer-layout>section:last-child{border:0}.producer-kicker{color:#8c9aad;font-size:.62rem;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.producer-layout h2{margin:7px 0 3px;font-size:1.05rem;letter-spacing:-.025em}.producer-meta,.producer-note-copy{color:#aab6c8;font-size:.78rem;line-height:1.45}.producer-note{margin:18px 0 12px;padding:11px;border:1px solid #293548;border-radius:8px;background:#101826}.producer-note strong{font-size:.68rem;text-transform:uppercase;letter-spacing:.1em;color:#8c9aad}.producer-note p{margin:7px 0 0;font-size:.8rem;line-height:1.45}.producer-preview{display:grid;min-height:115px;place-items:center;border-radius:8px;background:linear-gradient(145deg,#223344,#101923);overflow:hidden;text-align:center;padding:14px}.producer-preview img{width:100%;height:100%;object-fit:cover}.producer-topline{display:flex;align-items:start;justify-content:space-between;gap:10px}.producer-topline>span,.producer-live{color:#8c9aad;font-size:.72rem;font-weight:800;white-space:nowrap}.producer-live{padding:4px 7px;border-radius:99px;background:#173a36;color:#8cf0c6;text-transform:uppercase}.producer-flow ol{list-style:none;margin:18px 0 0;padding:0}.producer-flow li{display:grid;grid-template-columns:42px minmax(0,1fr) auto;gap:8px;align-items:center;padding:11px 5px;border-bottom:1px solid #202b3a}.producer-flow li.active{margin:0 -5px;padding-left:10px;border-radius:7px;background:#172231}.producer-flow li time,.producer-flow li span{color:#8491a4;font-size:.7rem}.producer-flow li strong{font-size:.86rem}.producer-flow .producer-section{display:block;padding:17px 0 6px;border:0;color:#8c9aad;font-size:.63rem;font-weight:900;letter-spacing:.15em;text-transform:uppercase}.producer-flow .producer-empty{display:block;color:#8c9aad}.producer-presentations{display:flex;gap:6px;overflow:auto;margin:15px 0 10px}.producer-presentation{flex:0 0 138px;display:grid;grid-template-columns:auto 1fr;gap:6px;padding:7px;border:1px solid #2b3748;border-radius:7px;background:#121a27;color:#e4ebf5;text-align:left}.producer-presentation.active{border-color:#68adff;box-shadow:inset 3px 0 #68adff}.producer-presentation span{color:#8291a5;font-size:.68rem}.producer-presentation strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.72rem}.producer-slide-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.producer-slide{position:relative;display:grid;grid-template-rows:70px auto auto;gap:4px;min-width:0;padding:4px;border:1px solid #293548;border-radius:7px;background:#06090e;color:#edf3fc;text-align:left}.producer-slide.active{border:2px solid #68adff;box-shadow:0 0 0 2px #68adff35}.producer-slide span{position:absolute;z-index:1;top:7px;left:7px;padding:2px 5px;border-radius:99px;background:#000c;font-size:.62rem;font-weight:850}.producer-slide img{width:100%;height:70px;object-fit:cover;border-radius:4px}.producer-slide strong{font-size:.66rem;line-height:1.15;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.producer-slide em{color:#8c9aad;font-size:.58rem;font-style:normal;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}@media(max-width:850px){.producer-layout{grid-template-columns:1fr;overflow:auto}.producer-layout>section{min-height:280px;border-right:0;border-bottom:1px solid #273142}.producer-slide-grid{grid-template-columns:repeat(4,minmax(0,1fr))}} .producer-presentations{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));overflow:visible;margin:15px 0 12px}.producer-presentation{min-width:0}.pp-presentation-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));overflow:auto;max-height:42%;padding:2px}.pp-presentation{min-width:0}@media(max-width:850px){.producer-presentations{grid-template-columns:repeat(3,minmax(0,1fr))}} diff --git a/docs/PROPRESENTER.md b/docs/PROPRESENTER.md index c30157f..9c0d0c7 100644 --- a/docs/PROPRESENTER.md +++ b/docs/PROPRESENTER.md @@ -76,9 +76,9 @@ In ChurchBoard's dashboard editor, select a **ProPresenter** widget and choose: Part labels use their ProPresenter colors. If parts do not change with the slide, confirm the cues/groups in the presentation are named and colored in ProPresenter. -For the **ProPresenter playlist** widget, select the focused playlist in ProPresenter. ChurchBoard renders its placeholders and presentations in playlist order, and shows every slide in a presentation continuously with its section marker beneath the thumbnail. On the live board itself, turn on **Slide controls** to make presentation headings and slide thumbnails clickable. Keep it off on public or read-only displays. The board editor lets you adjust slide, playlist-item, and section-marker scale, as well as preview the active-slide border color before saving. ChurchBoard retains the playlist scroll position as live information refreshes. +For the **ProPresenter playlist** widget, select the focused playlist in ProPresenter. ChurchBoard renders its placeholders and presentations in playlist order, and shows every slide in a presentation continuously with its section marker beneath the thumbnail. Turn on **Allow this widget to trigger ProPresenter** to make presentation headings and slide thumbnails clickable. Keep it off on public or read-only displays. The widget settings also let you adjust slide, playlist-item, and section-marker scale, as well as preview the active-slide border color before saving. ChurchBoard retains the playlist scroll position as live information refreshes. -Turn on **Arrow keys** in the live Playlist widget when an operator should drive ProPresenter from that board. **Slide controls** must also be on. Left Arrow or Up Arrow moves back; Right Arrow, Down Arrow, or Space advances. This choice is remembered by that browser and board. The command uses ProPresenter's global next/previous trigger, so it continues into the adjacent playlist item. Keyboard commands are ignored while the operator is typing in a field, using a button or menu, or holding a keyboard modifier. +Enable **Use arrow keys and spacebar to control ProPresenter** in that widget's editor when an operator should drive ProPresenter from the board. The widget's trigger option must also be on. Left Arrow or Up Arrow moves back; Right Arrow, Down Arrow, or Space advances. The command uses ProPresenter's global next/previous trigger, so it continues into the adjacent playlist item. Keyboard commands are ignored while the operator is typing in a field, using a button or menu, or holding a keyboard modifier. The focused playlist is used for the playlist browser, while the active playlist item is used for Planning Center Services LIVE matching. This distinction lets an operator inspect another presentation without making ChurchBoard leave the item that is actually on air. For Planning Center-synced content, the active playlist position remains authoritative when the local presentation name differs from the Planning Center item title—for example, a scripture presentation named `John 1:1-3 (ASB)` linked to the plan item `Message`. @@ -91,10 +91,10 @@ ProPresenter computer directly to browsers. ## Remote slide triggering Remote triggering is controlled independently for each ProPresenter Playlist -widget. On the live board, enable **Slide controls** only for displays used by -trusted operators on the production network. ChurchBoard validates the board -and widget for every trigger request; viewing another dashboard does not grant -it control. +widget. In the board editor, enable **Allow this widget to trigger +ProPresenter** only for displays used by trusted operators on the production +network. ChurchBoard validates the board and widget for every trigger request; +viewing another dashboard does not grant it control. When the active slide contains a ProPresenter timer element, ChurchBoard replaces its design-time placeholder with the currently running ProPresenter timer and composites that changing value over the otherwise static slide thumbnail. Video remaining time is kept separate and is never shown as a slide timer. For foreground video cues, ChurchBoard displays playing state, elapsed time, duration, and progress; looping motion backgrounds behind lyrics do not receive that overlay. ProPresenter's HTTP API exposes static cue/media thumbnails and transport information, but not live rendered audience-screen frames, so moving video cannot be reproduced from that API alone. A true moving preview would require a separate browser-compatible output stream from ProPresenter or a capture application such as OBS. diff --git a/tests/test_api.py b/tests/test_api.py index b14e8f1..962f9e7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -110,16 +110,7 @@ def test_dashboard_round_trip(self): self.assertEqual(saved_settings["position_keys"], ["band::vox 2", "band::vox 1"]) self.assertEqual(saved_settings["position_labels"]["band::vox 2"]["name"], "Vox 2") - def test_deleted_playlist_widget_stays_deleted(self): - board = self.client.get("/api/dashboards/main").json() - board["widgets"] = [widget for widget in board["widgets"] if widget["type"] != "playlist"] - response = self.client.put("/api/dashboards/main", json=board) - self.assertEqual(response.status_code, 200) - self.assertFalse(any(widget["type"] == "playlist" for widget in response.json()["widgets"])) - reloaded = self.client.get("/api/dashboards/main").json() - self.assertFalse(any(widget["type"] == "playlist" for widget in reloaded["widgets"])) - - def test_default_dashboards_include_a_configured_propresenter_playlist_widget(self): + def test_existing_dashboards_gain_a_propresenter_playlist_widget(self): board = self.client.get("/api/dashboards/main").json() playlist = next(widget for widget in board["widgets"] if widget["type"] == "playlist") self.assertTrue(playlist["settings"]["allow_remote_trigger"]) @@ -132,15 +123,9 @@ def test_default_dashboards_include_a_configured_propresenter_playlist_widget(se self.assertIn("playlist_item_size", editor) self.assertIn("playlist_marker_size", editor) self.assertIn("playlist_active_border_color", editor) - self.assertNotIn("playlist_keyboard_control", editor) - self.assertNotIn("playlist_allow_remote_trigger", editor) - display_script = self.client.get("/static/display.js").text - self.assertIn("data-pp-keyboard-toggle", self.client.get("/static/common.js").text) - self.assertIn("data-pp-controls-toggle", self.client.get("/static/common.js").text) - self.assertIn('class="pp-switch-track"', self.client.get("/static/common.js").text) - self.assertIn('role="switch"', self.client.get("/static/common.js").text) - self.assertIn("/api/integrations/propresenter/navigate/", display_script) - self.assertIn("keyboardStorageKey", display_script) + self.assertIn("playlist_keyboard_control", editor) + self.assertIn("playlist_allow_remote_trigger", editor) + self.assertIn("/api/integrations/propresenter/navigate/", self.client.get("/static/display.js").text) self.assertFalse(playlist["settings"]["keyboard_control"]) def test_runtime_and_manual_service_selection(self): diff --git a/tests/test_core.py b/tests/test_core.py index 1f4509b..44afa10 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1027,9 +1027,9 @@ def test_active_arrangement_repeats_groups_in_live_order(self): "arrangements": [{"id": {"uuid": "arrangement", "index": 0}, "groups": ["verse", "chorus", "bridge"], "total_cues": 4}], } entries = ProPresenterClient._presentation_cue_entries(presentation) - self.assertEqual([entry["part"] for entry in entries], ["Verse 2", "Verse 2", "Chorus 1", "Bridge"]) + self.assertEqual([entry["part"] for entry in entries], ["Blank", "Verse 2", "Verse 2", "Chorus 1", "Bridge"]) current, next_position = ProPresenterClient._cue_positions(entries, {"text": "Verse last"}, {"text": "Chorus line"}, 1) - self.assertEqual((current, next_position), (1, 2)) + self.assertEqual((current, next_position), (2, 3)) self.assertEqual(ProPresenterClient._cue_total({"presentation_index": {"total_cues": 4}}, len(entries)), 4) def test_nested_live_presentation_index_is_read(self): @@ -1097,36 +1097,6 @@ def test_video_remaining_time_is_not_a_lyric_timer(self): class ProPresenterPollingTests(unittest.IsolatedAsyncioTestCase): - async def test_thumbnail_route_converts_zero_based_cue_to_one_based_number(self): - class FakeResponse: - content = b"jpeg" - headers = {"content-type": "image/jpeg"} - - def raise_for_status(self): - return None - - class FakeHttp: - def __init__(self): - self.url = "" - - async def __aenter__(self): - return self - - async def __aexit__(self, *_args): - return None - - async def get(self, url, **_kwargs): - self.url = url - return FakeResponse() - - fake = FakeHttp() - client = ProPresenterClient({"enabled": True, "host": "127.0.0.1", "port": 50001}) - with patch("app.services.propresenter.httpx.AsyncClient", return_value=fake): - content, media_type = await client.thumbnail("ABC-123", 3) - self.assertEqual(content, b"jpeg") - self.assertEqual(media_type, "image/jpeg") - self.assertTrue(fake.url.endswith("/v1/presentation/ABC-123/thumbnail/4")) - async def test_active_playlist_context_drives_live_match_when_focus_is_elsewhere(self): class FakeResponse: def __init__(self, payload): From 8b6f71112851114c70a071cbc4b1252ec1321a77 Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 17:04:55 -0400 Subject: [PATCH 21/23] Group editor widgets by integration --- app/static/editor.html | 2 +- app/static/editor.js | 13 +++++++++++-- app/static/integration-palette.css | 12 ++++++++++++ tests/test_api.py | 6 ++++++ 4 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 app/static/integration-palette.css diff --git a/app/static/editor.html b/app/static/editor.html index b12179e..8090c4d 100644 --- a/app/static/editor.html +++ b/app/static/editor.html @@ -1,6 +1,6 @@ -Dashboard editor · ChurchBoard +Dashboard editor · ChurchBoard
ChurchBoard
All changes savedOpen display
diff --git a/app/static/editor.js b/app/static/editor.js index 7ed0085..e5f6928 100644 --- a/app/static/editor.js +++ b/app/static/editor.js @@ -7,7 +7,14 @@ document.querySelector("[name=order_limit]").closest("label").id="order-limit-la let dashboard,selected=null,dirty=false,catalogTeams=[],catalogError=""; const newId=type=>`${type}-${Date.now().toString(36)}`; const defaults={clock:{w:3,h:2},service:{w:5,h:2},timing:{w:4,h:2},assignments:{w:7,h:6,team_ids:[],position_keys:[],position_labels:{},display_mode:"photos",card_grouping:"person",use_planning_center_icon:false,unassigned_media_title:"Icon"},slides:{w:6,h:4,show_notes:true,slide_mode:"image",slide_layout:"full",show_current:true,show_next:true,show_parts:true,show_slide_count:false},playlist:{w:6,h:7,allow_remote_trigger:true,keyboard_control:false,slide_size:120,item_size:48,marker_size:10,active_border_color:"#f5c400"},notes:{w:4,h:2},order:{w:5,h:3,display_mode:"current",limit:6,show_leader:false,show_mic:false},people:{w:4,h:4,team_ids:[],position_keys:[],position_labels:{}},spl:{w:4,h:3,green_max:75,orange_max:85,weighting:"A",response:"Fast"},controls:{w:4,h:2},lighting:{w:6,h:4,allow_remote_trigger:true,page_size:18,scene_size:58,active_border_color:"#55e6a5",active_border_width:5},restream:{w:5,h:4},obs:{w:5,h:4},propresenter_timers:{w:5,h:3},text:{w:4,h:2,text:"Custom text"}}; -const paletteTypes=["clock","service","timing","assignments","slides","playlist","notes","order","people","spl","controls","lighting","restream","obs","propresenter_timers","text"]; +const integrationGroups=[ + {name:"ChurchBoard",icon:"⌘",types:["clock","service","timing","controls","text"]}, + {name:"Planning Center",icon:"P",types:["assignments","people","person","order"]}, + {name:"ProPresenter",icon:"▶",types:["slides","playlist","notes","propresenter_timers"]}, + {name:"Audio",icon:"♫",types:["spl"]}, + {name:"Lighting",icon:"✦",types:["lighting"]}, + {name:"Streaming",icon:"◉",types:["restream","obs"]}, +]; async function load(){ dashboard=await api(`/api/dashboards/${encodeURIComponent(slug)}`); @@ -51,7 +58,9 @@ function beginPointer(event,widget,resizing){ const move=pointer=>{const dx=Math.round((pointer.clientX-startX)/(column+10)),dy=Math.round((pointer.clientY-startY)/row);if(resizing){widget.w=Math.max(1,Math.min(dashboard.columns-widget.x,origin.w+dx));widget.h=Math.max(1,origin.h+dy)}else{widget.x=Math.max(0,Math.min(dashboard.columns-widget.w,origin.x+dx));widget.y=Math.max(0,origin.y+dy)}changed();render()}; const up=()=>{document.body.classList.remove("resizing-widget","moving-widget");window.removeEventListener("pointermove",move);window.removeEventListener("pointerup",up);select(widget.id)};window.addEventListener("pointermove",move);window.addEventListener("pointerup",up); } -paletteTypes.forEach(type=>{const name=widgetNames[type],button=document.createElement("button");button.className="palette-button";button.textContent=`+ ${name}`;button.onclick=()=>{const definition=defaults[type],bottom=Math.max(0,...dashboard.widgets.map(widget=>widget.y+widget.h));dashboard.widgets.push({id:newId(type),type,x:0,y:bottom,w:definition.w,h:definition.h,title:name,settings:Object.fromEntries(Object.entries(definition).filter(([key])=>!["w","h"].includes(key)))});changed();select(dashboard.widgets.at(-1).id)};document.querySelector("#widget-palette").append(button)}); +const addWidget=type=>{const definition=defaults[type],bottom=Math.max(0,...dashboard.widgets.map(widget=>widget.y+widget.h));dashboard.widgets.push({id:newId(type),type,x:0,y:bottom,w:definition.w,h:definition.h,title:widgetNames[type],settings:Object.fromEntries(Object.entries(definition).filter(([key])=>!["w","h"].includes(key)))});changed();select(dashboard.widgets.at(-1).id)}; +const palette=document.querySelector("#widget-palette"); +integrationGroups.forEach((group,index)=>{const details=document.createElement("details");details.className="integration-group";details.open=index===0;details.innerHTML=`${group.name}${group.types.length}
`;const widgets=details.querySelector(".integration-widgets");group.types.forEach(type=>{const button=document.createElement("button");button.type="button";button.className="palette-button";button.textContent=`+ ${widgetNames[type]}`;button.onclick=()=>addWidget(type);widgets.append(button)});palette.append(details)}); form.addEventListener("input",event=>{if(event.target.matches("[data-team-id],[data-position-key]"))return;const widget=find(selected),fields=form.elements;widget.title=fields.title.value;widget.settings.show_title=fields.show_title.checked;widget.w=Math.max(1,Math.min(dashboard.columns,Number(fields.w.value)||1));widget.h=Math.max(1,Number(fields.h.value)||1);if(widget.type==="text")widget.settings.text=fields.text.value;if(widget.type==="slides")Object.assign(widget.settings,{slide_layout:fields.slide_layout.value,slide_mode:fields.slide_mode.value,show_current:["both","current"].includes(fields.slide_visibility.value),show_next:["both","next"].includes(fields.slide_visibility.value),show_parts:fields.show_parts.checked,show_slide_count:fields.show_slide_count.checked,show_notes:fields.show_notes.checked});if(widget.type==="playlist"){Object.assign(widget.settings,{allow_remote_trigger:fields.playlist_allow_remote_trigger.checked,keyboard_control:fields.playlist_allow_remote_trigger.checked&&fields.playlist_keyboard_control.checked,slide_size:Math.max(80,Math.min(320,Number(fields.playlist_slide_size.value)||120)),item_size:Math.max(40,Math.min(120,Number(fields.playlist_item_size.value)||48)),marker_size:Math.max(8,Math.min(24,Number(fields.playlist_marker_size.value)||10))});fields.playlist_keyboard_control.disabled=!fields.playlist_allow_remote_trigger.checked}if(widget.type==="order")Object.assign(widget.settings,{display_mode:["full","fit"].includes(fields.order_display_mode.value)?fields.order_display_mode.value:"current",limit:Math.max(1,Math.min(20,Number(fields.order_limit.value)||6)),show_leader:fields.show_leader.checked,show_mic:fields.show_mic.checked});if(widget.type==="spl")Object.assign(widget.settings,{green_max:Number(fields.spl_green.value)||75,orange_max:Number(fields.spl_orange.value)||85,weighting:fields.spl_weighting.value,response:fields.spl_response.value});if(["assignments","mics"].includes(widget.type)){Object.assign(widget.settings,{display_mode:fields.assignment_mode.value,card_grouping:fields.assignment_grouping.value==="position"?"position":"person",use_planning_center_icon:fields.use_planning_center_icon.checked,unassigned_media_title:fields.unassigned_media_title.value.trim()||"Icon"});fields.unassigned_media_title.disabled=!fields.use_planning_center_icon.checked}if(widget.type==="order")document.querySelector("#order-limit-label").hidden=["full","fit"].includes(widget.settings.display_mode);setSlideControlState(widget,fields);changed();render()}); document.querySelector("#playlist-controls").addEventListener("input",event=>{if(event.target.name!=="playlist_active_border_color")return;const widget=find(selected);if(!widget||widget.type!=="playlist")return;widget.settings.active_border_color=event.target.value;changed();render()}); document.querySelector("#lighting-widget-controls").addEventListener("input",()=>{const widget=find(selected);if(!widget||widget.type!=="lighting")return;widget.settings.page_size=Math.max(12,Math.min(40,Number(form.elements.lighting_page_size.value)||18));widget.settings.scene_size=Math.max(34,Math.min(120,Number(form.elements.lighting_scene_size.value)||58));widget.settings.active_border_color=form.elements.lighting_active_border_color.value;widget.settings.active_border_width=Math.max(2,Math.min(12,Number(form.elements.lighting_active_border_width.value)||5));changed();render()}); diff --git a/app/static/integration-palette.css b/app/static/integration-palette.css new file mode 100644 index 0000000..499b116 --- /dev/null +++ b/app/static/integration-palette.css @@ -0,0 +1,12 @@ +.editor-shell{grid-template-columns:220px minmax(400px,1fr) 240px} +#widget-palette{gap:8px} +.integration-group{border:1px solid var(--line);border-radius:10px;background:#111824;overflow:hidden} +.integration-group summary{display:flex;align-items:center;gap:9px;padding:9px;cursor:pointer;font-size:.8rem;font-weight:800;list-style:none} +.integration-group summary::-webkit-details-marker{display:none} +.integration-group summary::after{content:"›";margin-left:auto;color:var(--muted);font-size:1.15rem;transition:transform .15s} +.integration-group[open] summary::after{transform:rotate(90deg)} +.integration-group summary small{display:grid;place-items:center;min-width:19px;height:19px;padding:0 5px;border-radius:99px;background:#273243;color:var(--muted);font-size:.62rem} +.integration-icon{display:grid;place-items:center;width:25px;height:25px;border-radius:7px;background:#2c3c54;color:#fff;font-size:.78rem;font-weight:900} +.integration-planning-center{background:#5a5ed1}.integration-propresenter{background:#da4f83}.integration-audio{background:#e08a39}.integration-lighting{background:#c99d2d}.integration-streaming{background:#2b9d86} +.integration-widgets{display:grid;gap:6px;padding:0 8px 8px} +.palette-button:hover{border-color:var(--blue);background:#202a39} diff --git a/tests/test_api.py b/tests/test_api.py index 962f9e7..b3cdfb3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -54,6 +54,12 @@ def test_setup_display_and_editor_pages_load(self): self.assertNotIn('name="pp_remote_control_enabled"', admin.text) self.assertIn('ProPresenter playlist', self.client.get("/static/common.js").text) self.assertIn('input name="show_parts" type="checkbox"', editor.text) + self.assertIn('integration-palette.css', editor.text) + editor_script = self.client.get("/static/editor.js").text + self.assertIn('name:"Planning Center"', editor_script) + self.assertIn('name:"ProPresenter"', editor_script) + self.assertIn('className="integration-group"', editor_script) + self.assertEqual(self.client.get("/static/integration-palette.css").status_code, 200) self.assertNotIn('id="dashboard-theme"', editor.text) self.assertNotIn('target="_blank"', editor.text) display_script = self.client.get("/static/display.js").text From c4a07eb702eb88288d4bc74f3e886b5d9ac1b85e Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 17:12:26 -0400 Subject: [PATCH 22/23] Organize widget palette by integration --- app/static/common.js | 2 +- app/static/editor.js | 4 ++-- app/store.py | 2 ++ tests/test_core.py | 9 +++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/static/common.js b/app/static/common.js index 7a367b3..cfc4695 100644 --- a/app/static/common.js +++ b/app/static/common.js @@ -145,7 +145,7 @@ const enhanceDynamicContent = (root=document) => { }));requestAnimationFrame(()=>resizeDashboardContent(root)); if(!root._churchBoardResizeObserver&&window.ResizeObserver){root._churchBoardResizeObserver=new ResizeObserver(()=>resizeDashboardContent(root));root._churchBoardResizeObserver.observe(root)} }; -const widgetNames = {clock:"Clock",service:"Service",timing:"Timers",assignments:"Scheduled Positions & Mics",mics:"Scheduled Positions & Mics",slides:"ProPresenter slides",playlist:"ProPresenter playlist",notes:"Slide notes",order:"Order of service",people:"Team members",spl:"Open Sound Meter",controls:"Service controls",lighting:"Lighting controls",person:"Scheduled person",restream:"Restream livestream",obs:"OBS live monitor",propresenter_timers:"ProPresenter timers",text:"Custom text"}; +const widgetNames = {clock:"Clock",service:"Service",timing:"Timers",assignments:"Scheduled Positions & Mics",mics:"Scheduled Positions & Mics",slides:"ProPresenter slides",playlist:"ProPresenter playlist",notes:"Slide notes",order:"Order of service",people:"Team members",spl:"Open Sound Meter",controls:"Service controls",lighting:"ShowXpress Control",person:"Scheduled person",restream:"Restream livestream",obs:"OBS live monitor",propresenter_timers:"ProPresenter timers",text:"Custom text"}; const widgetMarkup = (widget, state) => { const settings=widget.settings||{}, service=state.service||{}, timing=state.timing||{}, pp=state.propresenter||{}; let content=""; diff --git a/app/static/editor.js b/app/static/editor.js index e5f6928..f384fb3 100644 --- a/app/static/editor.js +++ b/app/static/editor.js @@ -8,8 +8,8 @@ let dashboard,selected=null,dirty=false,catalogTeams=[],catalogError=""; const newId=type=>`${type}-${Date.now().toString(36)}`; const defaults={clock:{w:3,h:2},service:{w:5,h:2},timing:{w:4,h:2},assignments:{w:7,h:6,team_ids:[],position_keys:[],position_labels:{},display_mode:"photos",card_grouping:"person",use_planning_center_icon:false,unassigned_media_title:"Icon"},slides:{w:6,h:4,show_notes:true,slide_mode:"image",slide_layout:"full",show_current:true,show_next:true,show_parts:true,show_slide_count:false},playlist:{w:6,h:7,allow_remote_trigger:true,keyboard_control:false,slide_size:120,item_size:48,marker_size:10,active_border_color:"#f5c400"},notes:{w:4,h:2},order:{w:5,h:3,display_mode:"current",limit:6,show_leader:false,show_mic:false},people:{w:4,h:4,team_ids:[],position_keys:[],position_labels:{}},spl:{w:4,h:3,green_max:75,orange_max:85,weighting:"A",response:"Fast"},controls:{w:4,h:2},lighting:{w:6,h:4,allow_remote_trigger:true,page_size:18,scene_size:58,active_border_color:"#55e6a5",active_border_width:5},restream:{w:5,h:4},obs:{w:5,h:4},propresenter_timers:{w:5,h:3},text:{w:4,h:2,text:"Custom text"}}; const integrationGroups=[ - {name:"ChurchBoard",icon:"⌘",types:["clock","service","timing","controls","text"]}, - {name:"Planning Center",icon:"P",types:["assignments","people","person","order"]}, + {name:"ChurchBoard",icon:"⌘",types:["clock","text"]}, + {name:"Planning Center",icon:"P",types:["service","timing","controls","assignments","people","person","order"]}, {name:"ProPresenter",icon:"▶",types:["slides","playlist","notes","propresenter_timers"]}, {name:"Audio",icon:"♫",types:["spl"]}, {name:"Lighting",icon:"✦",types:["lighting"]}, diff --git a/app/store.py b/app/store.py index 366fe91..5521a2a 100644 --- a/app/store.py +++ b/app/store.py @@ -113,6 +113,8 @@ def load(self) -> dict[str, Any]: ) dashboard["background_color"] = color if valid_color else "#0a0d12" for widget in dashboard.get("widgets", []): + if widget.get("type") == "lighting" and widget.get("title") == "Lighting controls": + widget["title"] = "ShowXpress Control" if widget.get("type") == "mics": widget["type"] = "assignments" if widget.get("title") in {"", "Microphones"}: diff --git a/tests/test_core.py b/tests/test_core.py index 44afa10..a32f67f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -63,6 +63,15 @@ def test_old_mic_widget_migrates_to_combined_assignments(self): self.assertEqual(widget["type"], "assignments") self.assertEqual(widget["title"], "Scheduled Positions & Mics") + def test_lighting_widget_title_migrates_to_showxpress_control(self): + with tempfile.TemporaryDirectory() as directory: + store = ConfigStore(Path(directory) / "state.json") + data = store.load() + data["dashboards"][0]["widgets"].append({"id": "lighting", "type": "lighting", "x": 0, "y": 10, "w": 6, "h": 4, "title": "Lighting controls", "settings": {}}) + store.save(data) + widget = next(item for item in store.load()["dashboards"][0]["widgets"] if item["id"] == "lighting") + self.assertEqual(widget["title"], "ShowXpress Control") + def test_order_widget_migrates_to_current_display_mode(self): with tempfile.TemporaryDirectory() as directory: store = ConfigStore(Path(directory) / "state.json") From f3603bd375efa0e649bca3513f8aac18a7d8910b Mon Sep 17 00:00:00 2001 From: Caleb Hines Date: Fri, 7 Aug 2026 17:31:10 -0400 Subject: [PATCH 23/23] Isolate macOS packaging workspace --- installers/macos/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installers/macos/build.sh b/installers/macos/build.sh index 817d2be..f83c500 100755 --- a/installers/macos/build.sh +++ b/installers/macos/build.sh @@ -25,7 +25,7 @@ PYTHON_BIN="$(find_python)" .build-venv/bin/pip install -r requirements.txt -r build-requirements.txt .build-venv/bin/python packaging/generate_brand_assets.py .build-venv/bin/python packaging/collect_licenses.py -.build-venv/bin/pyinstaller packaging/ChurchBoard.spec --noconfirm --clean +.build-venv/bin/pyinstaller packaging/ChurchBoard.spec --noconfirm --clean --workpath "$PROJECT_DIR/.pyinstaller-build" VERSION="$("$PYTHON_BIN" -c 'from app.version import __version__; print(__version__)')" ARCH="$(uname -m)"