diff --git a/README.md b/README.md
index 1f5eaa4..96aec90 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,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 an always-visible **Edit** button, right-click settings, independent layouts, and color-matched liquid-glass widgets for each destination
- Exportable/importable dashboard layout files and a categorized widget palette with modal settings and direct edge/corner resizing
@@ -98,7 +99,7 @@ Open `http://127.0.0.1:8040/admin`, turn off demonstration data, and configure P

-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 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/main.py b/app/main.py
index ae2c7b5..9aa8a08 100644
--- a/app/main.py
+++ b/app/main.py
@@ -29,6 +29,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__
@@ -71,6 +72,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
+
+
class MediaTagRulesRequest(BaseModel):
items: list[dict] = Field(default_factory=list)
@@ -315,12 +323,45 @@ 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)
return store.public_settings()
+@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:
+ return {"items": await client.buttons()}
+ except Exception as exc:
+ raise HTTPException(502, f"Could not read lighting controls: {exc}") from exc
+
+
+def require_lighting_widget_control(request: Request, dashboard_slug: str | None, widget_id: str | None) -> dict:
+ dashboard = dashboard_or_404(store_from(request), dashboard_slug or "")
+ widget = next((item for item in dashboard.get("widgets", []) if item.get("id") == widget_id), None)
+ if not widget or widget.get("type") != "lighting":
+ raise HTTPException(403, "Lighting controls must be triggered from a Lighting controls widget")
+ return store_from(request).load()["settings"].get("lighting", {})
+
+
+@app.post("/api/integrations/lighting/button")
+async def lighting_trigger_button(payload: LightingButtonTrigger, request: Request) -> dict:
+ client = TheLightingControllerClient(require_lighting_widget_control(request, payload.dashboard_slug, payload.widget_id))
+ if not client.configured:
+ raise HTTPException(400, "Lighting control is not connected")
+ try:
+ await client.trigger_button(payload.name, payload.mode)
+ return {"ok": True}
+ except Exception as exc:
+ raise HTTPException(502, f"Could not trigger lighting button: {exc}") from exc
+
+
@app.get("/api/auth/status")
async def auth_status(request: Request) -> dict:
auth: AuthManager = request.app.state.auth
diff --git a/app/models.py b/app/models.py
index 932bd64..462218d 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", "pp_controls", "notes", "sermon_notes", "order", "person", "people", "spl", "controls", "text", "restream", "livestreams", "obs", "propresenter_timers"]
+ type: Literal["clock", "service", "timing", "assignments", "mics", "slides", "playlist", "pp_controls", "notes", "sermon_notes", "order", "person", "people", "spl", "controls", "lighting", "text", "restream", "livestreams", "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,6 +54,7 @@ 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)
server: 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..88427c2
--- /dev/null
+++ b/app/services/thelightingcontroller.py
@@ -0,0 +1,148 @@
+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)."""
+
+ # 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
+
+ @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:
+ return await self._button_list(reader, writer)
+ 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")
+ 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)
+ 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.
+ 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()
+
+ 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:
+ 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", 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:
+ 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 _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"))
+ 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"
+ page_columns = int(page.get("columns") or 0)
+ 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) + 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/app/static/admin.html b/app/static/admin.html
index 531879e..aaddeaf 100644
--- a/app/static/admin.html
+++ b/app/static/admin.html
@@ -9,6 +9,7 @@
+
diff --git a/app/static/common.js b/app/static/common.js
index 4ebfc27..07d5e00 100644
--- a/app/static/common.js
+++ b/app/static/common.js
@@ -159,12 +159,13 @@ 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",pp_controls:"ProPresenter controls",notes:"Slide notes",sermon_notes:"Sermon notes",order:"Order of service",people:"Team members",spl:"Open Sound Meter",controls:"Service controls",person:"Scheduled person",restream:"Restream livestream",livestreams:"Livestream status",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",pp_controls:"ProPresenter controls",notes:"Slide notes",sermon_notes:"Sermon notes",order:"Order of service",people:"Team members",spl:"Open Sound Meter",controls:"Service controls",lighting:"ShowXpress Control",person:"Scheduled person",restream:"Restream livestream",livestreams:"Livestream status",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="";
if(widget.type==="playlist") content=propresenterPlaylistMarkup(pp,settings.allow_remote_trigger!==false,settings);
if(widget.type==="pp_controls") content=`${escapeHtml(presentationDisplayTitle(pp))}
`;
+ if(widget.type==="lighting") content=`Loading exposed lighting controls…
`;
if(widget.type==="clock") content=``;
if(widget.type==="service") { const timingLabel=timing.source==="planning_center_live"?"Planning Center LIVE":timing.state||"scheduled";content=service.id?`${escapeHtml(service.title||service.service_type_name)}
${escapeHtml(service.dates||"")} · ${escapeHtml(timingLabel)}
`:`No service is active
`; }
if(widget.type==="timing") { const item=timing.current_item,rehearsal=timing.rehearsal===true; content=`${rehearsal?'
REHEARSAL TIMING
':""}
${escapeHtml(item?.title||"Current item")}
${formatDuration(timing.item_delta||0)}
Overall
${formatDuration(timing.overall_delta||0)}
`; }
diff --git a/app/static/display.js b/app/static/display.js
index 997bdd8..7dd8868 100644
--- a/app/static/display.js
+++ b/app/static/display.js
@@ -16,6 +16,9 @@ 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=>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:"press",...triggerScope(button)})});lightingButtonsCache=null}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}});
document.addEventListener("click",async event=>{const button=event.target.closest("[data-pp-nav],[data-pp-item-nav]");if(!button||button.disabled)return;const direction=button.dataset.ppNav||button.dataset.ppItemNav,endpoint=button.dataset.ppItemNav?"navigate-item":"navigate",status=button.closest(".pp-control-pad")?.querySelector("[data-pp-control-status]");button.disabled=true;if(status)status.textContent=direction==="next"?"Advancing ProPresenter…":"Going back in ProPresenter…";try{await api(`/api/integrations/propresenter/${endpoint}/${direction}`,{method:"POST",body:JSON.stringify(triggerScope(button))});await refresh(true)}catch(error){if(status)status.textContent=error.message}finally{button.disabled=false}});
@@ -101,6 +104,7 @@ function render(){
updatePlaylistLiveState(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])}
@@ -121,6 +125,7 @@ function widgetStateKey(widget,state){
if(widget.type==="restream")return`restream:${JSON.stringify(state.restream||{})}`;
if(widget.type==="livestreams")return`livestreams:${JSON.stringify([state.livestreams||[],settings.sources||[]])}`;
if(widget.type==="propresenter_timers")return`propresenter-timers:${JSON.stringify(pp.timers||[])}`;
+ if(widget.type==="lighting")return`lighting:${JSON.stringify(settings)}`;
return`${widget.type}:${JSON.stringify(state)}`;
}
function updatePlaylistLiveState(root=document){const pp=lastState.propresenter||{},uuid=String(pp.presentation_uuid||""),slide=Number(pp.current?.index)||0;root.querySelectorAll('[data-widget-type="playlist"]').forEach(widget=>{const widgetId=String(widget.dataset.widget||""),key=`${uuid}:${slide}`;widget.querySelectorAll("[data-pp-item-uuid]").forEach(item=>{const active=String(item.dataset.ppItemUuid||"")===uuid;item.classList.toggle("active",active);const status=item.querySelector("[data-pp-item-status]");if(status)status.textContent=active?"On air":status.dataset.idleLabel||""});widget.querySelectorAll("[data-pp-slide-uuid]").forEach(item=>item.classList.toggle("active",String(item.dataset.ppSlideUuid||"")===uuid&&Number(item.dataset.ppSlideNumber)===slide));if(playlistActiveKeys.get(widgetId)===key)return;playlistActiveKeys.set(widgetId,key);const configured=(dashboard?.widgets||[]).find(item=>String(item.id)===widgetId);if(configured?.settings?.auto_scroll===false)return;const target=widget.querySelector(".pp-list-slide.active")||widget.querySelector(".pp-list-presentation.active");target?.scrollIntoView({block:"nearest",inline:"nearest",behavior:"smooth"})})}
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/app/static/style.css b/app/static/style.css
index 6494d79..752dce3 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -61,6 +61,8 @@
.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;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: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}
@@ -113,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/app/store.py b/app/store.py
index 430f8ec..011fe59 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": ""},
"server": {"port": 8040, "https_enabled": False, "ssl_certfile": "", "ssl_keyfile": ""},
"position_mic_map": {"Vox 1": "mic-1", "Vox 2": "mic-2"},
"manual_plan": None,
@@ -109,7 +110,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", "server"):
+ for section in ("planning_center", "propresenter", "shure", "sennheiser", "open_sound_meter", "restream", "obs", "lighting", "server"):
baseline["settings"][section] = {
**default_data()["settings"][section],
**raw.get("settings", {}).get(section, {}),
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)"