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 ![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 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 @@
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 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.

  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/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)"