StemStage + Holyrics

Востаннє оновлено: 2026-10-10

«Транслювати в Holyrics» — StemStage на сценічному ноуті надсилає поточний слайд у Holyrics на ноуті в залі, і проєктор показує його вже Holyrics: вашими темами, шрифтами й фонами. На цій сторінці — усе, що треба налаштувати один раз: API Server і дозволи токена в Holyrics, підключення в StemStage, тема слайдів і кнопка-перемикач трансляції на панелі Holyrics.

Перевірено на Holyrics 2.29.1. Трансляція працює лише з десктопного застосунку StemStage — у браузерній версії її немає.

1. Holyrics: API Server і токен

  1. Holyrics → Налаштування (шестерня) → вкладка API Server.
  2. API Server – Local → Увімкнено. Порт за замовчуванням — 8091; якщо змінюєте, той самий порт потім вводите в StemStage.
  3. Керувати дозволами → створіть токен для StemStage і позначте йому дії з таблиці нижче. Скопіюйте токен — він знадобиться в розділі 2.
  4. На ноуті в залі порт API Server має бути відкритий у фаєрволі: запити йдуть зі сценічного ноута в зал.

Дозволи токена

Holyrics видає дозволи по одній дії — варіанта «всі дії» немає. Токену StemStage потрібні:

Дія Навіщо Потрібна?
ShowQuickPresentation показати слайд — основна дія трансляції так
CloseCurrentPresentation прибрати слайд: пауза, інструментал, вимкнення трансляції так
GetCurrentPresentation кнопка «Перевірити» в налаштуваннях StemStage (лише читання) так
GetInterfaceInput читає тему головного вікна — для скрипту теми (розділ 3, спосіб Б) так, якщо ставите скрипт теми
GetCurrentTheme діагностика: тема слайда, що зараз на екрані ні
GetThemes діагностика: список тем з id і назвами ні

Дії гортання (ActionNext, ActionPrevious тощо) не потрібні: StemStage щоразу надсилає готовий слайд, а не гортає презентацію Holyrics.

2. StemStage: підключення

У десктопному застосунку на сценічному ноуті: Налаштування → «Мережа» → Holyrics — адреса ноута в залі, порт (8091), токен із розділу 1 → «Перевірити». Має з’явитися «Зв’язок є — останній запит пройшов».

Те саме з консолі stemstage (наприклад, по ssh на сценічний ноут):

echo "$TOKEN" | stemstage holyrics set --host 192.168.x.x --port 8091 --token -
stemstage holyrics check   # перевірити зв'язок
stemstage holyrics on      # увімкнути трансляцію (off — вимкнути)
stemstage holyrics         # показати поточний стан

--token - читає токен рядком зі stdin (з переводом рядка — echo його додає). Просто в рядку команди токен не передається навмисно: там його було б видно іншим користувачам системи.

Токен зберігається лише на цьому пристрої й не синхронізується через хмару.

Сама трансляція вмикається кнопкою «Транслювати в Holyrics» на вкладці «Показ» або на пульті /control (на пульті — лише коли ввімкнена «Трансляція в локальну мережу»).

3. Тема слайдів

Без налаштування слайди зі StemStage ідуть першою темою зі списку Holyrics, хоч би яку тему вибрали в головному вікні. Причина: StemStage показує слайд як швидку презентацію, а вона бере тему не з головного вікна, а з власного вікна швидкої презентації (кнопка в лівому верхньому куті Holyrics, поруч із кнопкою переходу до Біблії). Поки там нічого не вибрано — це перша тема.

Є два способи це змінити — обидва в самому Holyrics.

Спосіб А: без скрипту — тема у вікні швидкої презентації

Відкрийте вікно швидкої презентації (кнопка в лівому верхньому куті) і виберіть там тему. Мінус: це окремий вибір — тема головного вікна на трансляцію не впливає, і змінювати тему доведеться в обох місцях.

Спосіб Б: скрипт — тема головного вікна (рекомендовано)

Невеликий скрипт API-сервера Holyrics перехоплює кожен слайд, що прийшов без теми, і показує його темою, вибраною в головному вікні. Оператор вибирає тему як завжди — і наступний слайд зі StemStage вже йде нею. Якщо щось піде не так, скрипт мовчки показує слайд як раніше: слайд на проєкторі буде завжди.

Куди вставляти

  1. Holyrics → Налаштування → вкладка API Server → кнопка JavaScript (нижній ряд, поруч із «Допомога» і «Log»). Відкриється редактор із двома полями коду.
  2. Ліве поле — основна функція request(...): виділіть усе (Ctrl+A), видаліть шаблон і вставте ліву половину нижче.
  3. Праве поле — «додаткові функції»: вставте праву половину. Копіюйте до останньої } включно.
  4. Зберегти внизу редактора → Ok у вікні налаштувань.
  5. Додайте токену StemStage дозвіл GetInterfaceInput (розділ 1).

Якщо в лівому полі вже є ваш власний код (інша інтеграція), не видаляйте його — додайте лише рядки case 'ShowQuickPresentation': ... у його switch. return true в кінці мусить лишитися: саме він каже Holyrics обробити всі інші запити як зазвичай.

Ліва половина

function request(action, headers, content, info) {
  switch (action) {
    case 'ShowQuickPresentation':
      return ssShowWithMainTheme(content);
  }
  // Усі інші дії Holyrics виконує сам, як без скрипту.
  return true;
}

Права половина

// Слова зі StemStage приходять як ShowQuickPresentation без теми. Підставляємо тему, вибрану
// в головному вікні Holyrics. Що б не пішло не так — повертаємо true: Holyrics покаже слайд
// як раніше (першою темою), але слайд на проєкторі буде завжди.
function ssShowWithMainTheme(content) {
  // Тема вже є в запиті (інший клієнт API або повторний вхід) — нічого не чіпаємо.
  if (content == null || content.theme != null) return true;
  try {
    var t = h.hly('GetInterfaceInput', { id: 'main_selected_theme' });
    if (t == null || String(t.status) != 'ok' || !t.data) return true;
    content.theme = { id: String(t.data) };
    var r = h.hly('ShowQuickPresentation', content);
    if (r != null && String(r.status) == 'ok') return r;
    h.log('[stemstage] показ із темою не вдався: ' + JSON.stringify(r));
  } catch (e) {
    h.log('[stemstage] тема: ' + e);
  }
  // Запасний шлях: штатний показ без теми.
  delete content.theme;
  return true;
}

Скрипт діє на всі програми, що надсилають у Holyrics швидку презентацію без теми, не лише на StemStage, — вони теж підуть темою головного вікна. Запит, у якому тему вказано явно, скрипт не чіпає.

Перевірка

  1. У головному вікні Holyrics виберіть тему, помітно відмінну від першої в списку.
  2. Увімкніть «Транслювати в Holyrics» і запустіть пісню — слайд має йти вибраною темою.
  3. Змініть тему в головному вікні — наступний слайд піде новою (поточний не перемальовується, поки StemStage не надішле наступний).
  4. Не підхопилось — API Server → Log, рядки [stemstage]. Помилка дозволу на GetInterfaceInput — додайте його токену.

Як прибрати

Видаліть обидва фрагменти (у лівому полі лишіть request лише з return true;) → Зберегти. Трансляція повернеться до теми вікна швидкої презентації.

4. Кнопка «Слова зі StemStage» на панелі Holyrics

Кнопка на панелі швидкого доступу Holyrics, яка вмикає й вимикає «Транслювати в Holyrics» — ту саму трансляцію, що й кнопка на вкладці «Показ» чи на пульті /control. Оператор у залі керує словами, не відходячи від Holyrics. Дозволи токена для неї не потрібні: кнопка ходить не в API Holyrics, а до StemStage на сцені.

Що потрібно

Крок 1. Приймач

Holyrics 2.29.1 не приймає повну адресу ws://… у скрипті (receiver not found) — потрібен приймач — окреме з’єднання, яке Holyrics налаштовує у вікні «Приймачі».

  1. «Приймачі» → «Створити», тип URL.
  2. Заповніть:
    • Назва: stemstage — саме так, скрипт шукає приймач за цією назвою.
    • URL/Host/ip:port: ws://192.168.2.3:7373/lan/ws — між IP і портом двокрапка, не /.
    • Тип: WebSocket.
  3. «Ок».

Крок 2. Кнопка-скрипт

  1. Панель швидкого доступу → додати елемент JavaScript.
  2. Назва — те, що оператор бачить на кнопці, наприклад «Слова зі StemStage». Стан (ON / OFF / «(немає зв’язку)») скрипт дописує сам, у назву його не пишіть.
  3. «JavaScript Code» — відкриється редактор із двома половинами:
    • ліва — основний код; Holyrics вимагає, щоб він починався рядком function scriptAction(obj) {, інакше при збереженні буде «‘function scriptAction(obj) {’ не знайдено»;
    • права — «додаткові функції» (Function Input / Item Status).
  4. Вставте код нижче в обидві половини, у правій замініть STEMSTAGE_HOST і STEMSTAGE_PIN на свої. Копіюйте до останньої } включно — загублена дужка дає Expected } but found eof.
  5. «Зберегти».

Ліва половина

function scriptAction(obj) {
  var client = ssConnect();
  var known = h.getGlobal('stemstage_holyrics_active', null);
  if (client.isOpen() && h.getGlobal('stemstage_ready', false) && known !== null) {
    ssSendCmd(client, !known);
  } else {
    h.setGlobal('stemstage_pending_toggle', true); // перемкне, щойно прийде стан
  }
}

Права половина

// IP:порт сценічного ноута — для заголовка Origin (сама адреса з'єднання — у приймачі `stemstage`).
var STEMSTAGE_HOST = '192.168.2.3:7373';
// PIN LAN-пульта (той самий, що на /control).
var STEMSTAGE_PIN = '000000';
var CACHE_ID = 'stemstage-lan';

function hGetItemStatusData(obj) {
  var active = h.getGlobal('stemstage_holyrics_active', null);
  if (active === null) return { active: false, background: '5A5A5A', description: '(немає зв’язку)' };
  if (active) return { active: true, description: 'ON' };
  return { active: false, description: 'OFF' };
}

function ssLog(msg) { h.log('[stemstage] ' + msg); }

function ssSendCmd(client, active) {
  client.send(JSON.stringify({ t: 'cmd', payload: { type: 'set-holyrics-broadcast', active: active } }));
}

function ssConnect() {
  var url = 'stemstage'; // назва (або ID) приймача з кроку 1
  var existing = h.ws(url, CACHE_ID);
  if (existing != null && existing.isOpen()) return existing;
  h.setGlobal('stemstage_ready', false);
  var client = h.ws(url, CACHE_ID, {
    // LAN-сервер пускає сокет лише коли Origin збігається з адресою, на яку дзвонять.
    headers: { Origin: 'http://' + STEMSTAGE_HOST },
    on_open: function (evt) {
      evt.source.send(JSON.stringify({ t: 'hello', role: 'control', version: 1 }));
    },
    on_message: function (raw) {
      var m;
      try { m = JSON.parse(raw); } catch (e) { return; }
      if (m.t === 'challenge') {
        var proof = String(h.sha256Str(m.nonce + STEMSTAGE_PIN)).toLowerCase();
        client.send(JSON.stringify({ t: 'auth', proof: proof }));
      } else if (m.t === 'auth-failed') {
        ssLog('невірний PIN або блокування: ' + m.reason);
      } else if (m.t === 'no-host') {
        h.setGlobal('stemstage_ready', false);
        ssLog('StemStage не відкритий на сцені');
      } else if (m.t === 'ready') {
        h.setGlobal('stemstage_ready', true);
      } else if (m.t === 'host' && m.payload) {
        var p = m.payload;
        if ((p.type === 'show-state' || p.type === 'snapshot') && typeof p.holyricsActive === 'boolean') {
          h.setGlobal('stemstage_holyrics_active', p.holyricsActive);
          if (h.getGlobal('stemstage_pending_toggle', false)) {
            h.setGlobal('stemstage_pending_toggle', false);
            ssSendCmd(client, !p.holyricsActive);
          }
        }
      }
    },
    on_error: function (evt) { ssLog('помилка з’єднання: ' + evt.error); },
    on_close: function (evt) {
      h.setGlobal('stemstage_ready', false);
      h.setGlobal('stemstage_holyrics_active', null);
    }
  });
  return client;
}

Як користуватись

Якщо кнопка не працює

Налагоджувальна кнопка — та сама кнопка, але з логом на кожному кроці

Окрема кнопка з тим самим протоколом, але з логом на кожному кроці. Має власне з’єднання й власні змінні (ssdbg_*), тож основній кнопці не заважає; теж справді перемикає трансляцію — перевіряйте поза служінням. Лог — вікно JavaScript Log (або кнопка «Тест» у редакторі); у самому приймачі є ще кнопка Log — сирий трафік з’єднання.

Натисніть її двічі з паузою 2–3 с і читайте ланцюжок: відкриваю → з’єднано → challenge → PIN прийнято → готово → стан трансляції.

Ліва половина

function scriptAction(obj) {
  try {
    var client = dbgConnect();
    var known = h.getGlobal('ssdbg_active', null);
    var ready = h.getGlobal('ssdbg_ready', false);
    dbgLog('клік: open=' + client.isOpen() + ' ready=' + ready + ' state=' + known);
    if (client.isOpen() && ready && known !== null) {
      dbgLog('надсилаю set-holyrics-broadcast active=' + !known);
      dbgSendCmd(client, !known);
    } else {
      dbgLog('ще не готово — перемкну, коли прийде стан');
      h.setGlobal('ssdbg_pending', true);
    }
  } catch (e) {
    dbgLog('exception у кліку: ' + e);
  }
}

Права половина

var DBG_HOST = '192.168.2.3:7373';
var DBG_PIN = '000000';
var DBG_CACHE_ID = 'stemstage-lan-debug';

function hGetItemStatusData(obj) {
  var active = h.getGlobal('ssdbg_active', null);
  if (active === null) return { active: false, background: '5A5A5A', description: '(немає зв’язку)' };
  if (active) return { active: true, description: 'ON' };
  return { active: false, description: 'OFF' };
}

function dbgLog(msg) { h.log('[stemstage-debug] ' + msg); }

function dbgSendCmd(client, active) {
  client.send(JSON.stringify({ t: 'cmd', payload: { type: 'set-holyrics-broadcast', active: active } }));
}

function dbgConnect() {
  var url = 'stemstage'; // назва (або ID) приймача
  var existing = null;
  try {
    existing = h.ws(url, DBG_CACHE_ID);
  } catch (e) {
    dbgLog('exception при пошуку з’єднання: ' + e);
  }
  if (existing != null) {
    dbgLog('знайдено з’єднання, open=' + existing.isOpen());
    if (existing.isOpen()) return existing;
  }
  h.setGlobal('ssdbg_ready', false);
  dbgLog('відкриваю приймач ' + url + ' з Origin http://' + DBG_HOST);
  var client;
  try {
    client = h.ws(url, DBG_CACHE_ID, {
      headers: { Origin: 'http://' + DBG_HOST },
      on_open: function (evt) {
        dbgLog('з’єднано, HTTP ' + evt.http_status + ' ' + evt.http_status_message);
        evt.source.send(JSON.stringify({ t: 'hello', role: 'control', version: 1 }));
        dbgLog('надіслано hello');
      },
      on_message: function (raw) {
        var m;
        try { m = JSON.parse(raw); } catch (e) { dbgLog('не JSON: ' + String(raw).substring(0, 200)); return; }
        var p = m.payload;
        dbgLog('отримано t=' + m.t + (p && p.type ? ' payload.type=' + p.type : ''));
        if (m.t === 'challenge') {
          var proof = String(h.sha256Str(m.nonce + DBG_PIN)).toLowerCase();
          dbgLog('challenge, nonce=' + m.nonce + ' proof=' + proof);
          client.send(JSON.stringify({ t: 'auth', proof: proof }));
        } else if (m.t === 'auth-ok') {
          dbgLog('PIN прийнято');
        } else if (m.t === 'auth-failed') {
          dbgLog('PIN відхилено: ' + m.reason + (m.retryAfterSec ? ', повтор через ' + m.retryAfterSec + ' с' : ''));
        } else if (m.t === 'no-host') {
          h.setGlobal('ssdbg_ready', false);
          dbgLog('на сцені не відкритий StemStage (no-host)');
        } else if (m.t === 'version-mismatch') {
          dbgLog('різні версії протоколу: host=' + m.hostVersion + ' client=' + m.clientVersion);
        } else if (m.t === 'ready') {
          h.setGlobal('ssdbg_ready', true);
          dbgLog('готово, чекаю стан');
        } else if (m.t === 'host' && p) {
          if ((p.type === 'show-state' || p.type === 'snapshot') && typeof p.holyricsActive === 'boolean') {
            dbgLog('стан трансляції: ' + p.holyricsActive + ', health=' + p.holyricsHealth);
            h.setGlobal('ssdbg_active', p.holyricsActive);
            if (h.getGlobal('ssdbg_pending', false)) {
              h.setGlobal('ssdbg_pending', false);
              dbgLog('відкладене перемикання → ' + !p.holyricsActive);
              dbgSendCmd(client, !p.holyricsActive);
            }
          }
        }
      },
      on_error: function (evt) {
        dbgLog('помилка з’єднання: ' + evt.error);
      },
      on_close: function (evt) {
        dbgLog('з’єднання закрито: code=' + evt.code + ' reason=' + evt.reason);
        h.setGlobal('ssdbg_ready', false);
        h.setGlobal('ssdbg_active', null);
      }
    });
  } catch (e) {
    dbgLog('exception при відкритті: ' + e);
    throw e;
  }
  return client;
}

Типові помилки

Симптом Причина Що зробити
При збереженні: «‘function scriptAction(obj) {’ не знайдено» Ліва половина не починається з цього рядка Перший рядок лівої половини — рівно function scriptAction(obj) {
При збереженні: Expected } but found eof При копіюванні загубилась остання } Докопіюйте кінець правої половини
У лозі receiver not found: ws://… У скрипті повна адреса замість приймача var url = 'stemstage' + приймач із кроку 1
Кнопка весь час сіра, у лозі нічого Приймач з помилкою в адресі (напр. /7373 замість :7373) Виправити URL приймача
з’єднання закрито одразу після відкриваю LAN-сервер відхилив сокет: не той Origin або LAN-сервер вимкнений STEMSTAGE_HOST = той самий IP:порт, що в приймачі; перевірити, що /control відкривається
PIN відхилено Невірний PIN (після кількох спроб — тимчасове блокування) Виправити STEMSTAGE_PIN, зачекати retryAfterSec
no-host На сцені не відкрита вкладка StemStage Відкрити StemStage на сценічному ноуті
Після оновлення StemStage кнопка сіра, хоча слова приходять У приймачі й скрипті лишився старий порт 47811 Порт 7373 у приймачі stemstage і в STEMSTAGE_HOST (та DBG_HOST); на сцені відкрити його у фаєрволі: sudo firewall-cmd --permanent --add-port=7373/tcp && sudo firewall-cmd --reload
Слова в Holyrics приходять, а кнопка сіра Це різні канали: слова йдуть HTTP API Holyrics, кнопка — WebSocket до сцени Дивитись лог налагоджувальної кнопки

5. Якщо не працює

Стан під кнопкою «Перевірити» в налаштуваннях StemStage:

Повідомлення Що це Що робити
«Зв’язок є — останній запит пройшов.» усе гаразд —
«Holyrics не відповідає…» ноут у залі вимкнений, невірна адреса чи порт, закритий фаєрвол, вимкнений API Server розділ 1 (кроки 2 і 4), адреса й порт — розділ 2
«Токен без потрібного дозволу…» або токен невірний, або йому бракує дозволу на дію перевірте токен і дозволи з розділу 1
«Заповніть адресу, порт і токен нижче.» підключення не задане розділ 2
«Немає звʼязку з локальним сервером…» не працює локальний сервер застосунку секція «Локальний сервер» у тих самих налаштуваннях

Для технічної перевірки з будь-якого комп’ютера в мережі (тіло запиту й заголовок Content-Type обов’язкові):

H=http://192.168.x.x:8091/api; T=<токен>
# зв'язок і токен:
curl -sS -X POST -H 'Content-Type: application/json' -d '{}' "$H/GetCurrentPresentation?token=$T"
# тема слайда, що зараз на екрані (дозвіл GetCurrentTheme):
curl -sS -X POST -H 'Content-Type: application/json' -d '{}' "$H/GetCurrentTheme?token=$T"
# тема головного вікна (дозвіл GetInterfaceInput):
curl -sS -X POST -H 'Content-Type: application/json' -d '{"id":"main_selected_theme"}' "$H/GetInterfaceInput?token=$T"

"unauthorized action" у відповіді — токену бракує дозволу на цю дію; "invalid token" — токен невірний.