diff --git a/README.md b/README.md index 043137a..b3b08de 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ host service. ## Features -- Shared text layout in the controller preview and reading screen. +- [Multiple reading screens](docs/displays.md) with synchronized passages, exact simultaneous previews, format comparison and individual display calibration. - Speed control, sections, horizontal/vertical mirrors, countdown and [configurable remotes and pedals](docs/remotes.md). - Offline native speech on macOS; Vosk on Windows/Linux; on-device recognition on supported Android devices. - Script library, TXT/Markdown/DOCX import, settings profiles and portable JSON backups. diff --git a/docs/displays.md b/docs/displays.md new file mode 100644 index 0000000..dcefbec --- /dev/null +++ b/docs/displays.md @@ -0,0 +1,94 @@ +# Several teleprompters in one session + +One server can relay to several reading screens. Connect each screen with the +same QR/link; use one controller as the operator. Native local sessions remain +one shared session, without internet. Hosted sessions use the same display +messages over the existing WebRTC transport. + +## Leading screen and synchronized reading + +**Leading teleprompter** chooses the screen that owns the scrolling position and +pace. Other screens follow its passage, including their own line wrapping. The +protocol carries the words at adjacent source line starts and progress between +them; followers interpolate those word positions in their layout. Matching +viewports and layout settings preserve exact pixels. Different font sizes or +margins use the word anchor even when the screen dimensions are identical. + +Play/pause, speed, section jumps, pedal scrolling and seeking from any reader +control the shared session. The controller resolves sections once against the +leading layout. A follower's drag/wheel seek is translated from its own layout. +Only the leading reader uses its microphone when reader-side voice is selected; +controller-side speech is still shared by all readers. + +If the leading screen disconnects or becomes hidden, the controller chooses an +available visible reader and keeps its current passage and playback state. +Selecting another leading screen can change the physical scrolling pace because +its wrapping becomes authoritative. A short interruption can leave screens +running from their last state until the connection recovers; they realign from +the leading screen. This is software prompting, not frame-locked video output. + +## Exact previews and format comparison + +**All screens** shows the leading preview and each other screen at its actual +CSS viewport dimensions, font, margins, marker and orientation. Each preview is +scaled as a whole. It follows that reader's acknowledged state rather than +inventing a common picture. Disconnected secondary displays show a dimmed last +frame with an explicit disconnected label; they do not continue animating. +A preview without state updates for more than 3.5 seconds freezes and is explicitly marked as stale. Turning off the grid stops its animation loops. + +**Compare formats** shades the region outside the intersection of the compared +screen formats. Each other rectangle fits inside the target rectangle without +changing its aspect ratio; horizontal centers and reading lines are aligned. +For 1600×1000 (16:10) and 1600×900 (16:9), with reading lines at 40%, the taller +preview has 40 px shaded above and 60 px below the smaller format. The wider +format can show shaded side strips when compared to a taller aspect ratio. +The comparison respects vertical mirroring when mirrored preview is enabled. + +This is a geometry guide. Different fonts, margins and wrapping mean it cannot +promise that every word inside a rectangle is visible on every screen. Use the +individual previews to inspect the actual text. The shaded region appears on +the operator preview only; it does not darken the reading screens. + +## Independent display settings + +Open **Settings for this screen** under a preview or on the reader. Name a +screen (for example Camera 1), and enable overrides for font size, symmetric or +separate left/right margins, top/bottom padding, reading-line position and +horizontal/vertical mirroring. Omitted overrides inherit the shared controller +settings. **Use shared settings** removes overrides while retaining the name. +An explicit left/right margin takes precedence over the symmetric side margin. + +The reader URL includes `?screen=…`, a stable display identifier distinct from +its network connection ID. Keep/bookmark this link to reopen the same display +profile. Profiles are saved on the reader and operator, survive reload and are +resent after reconnect. If the same saved reader link is opened concurrently, the newer reader takes a distinct identity and copies the calibration. Different windows opened with the controller's new-window +button receive distinct display IDs. Opening a fresh QR link creates a new +screen identity; it does not silently reuse another screen's calibration. + +The server relays profiles but does not persist them. Device-local calibrations +are not part of portable script/settings backups. All clients in a production +session should use the same app version. Real Safari/WKWebView, WebView2, +WebKitGTK and Android hardware still need device checks; browser automation +cannot certify monitor timing or physical screen behavior. + +## Verification + +Checked on October 12, 2026 with Chromium and the production Swift relay: + +- Three independent readers: 1600×1000, 1600×900 and 500×900, including different fonts on equal-size viewports. +- A 2600-word chunked script, 14 seconds of playback at speed 10, per-screen preview geometry and shared follower seeking. +- Display calibration and identity persistence, concurrent duplicate links, rotation, controller reload, leader selection/disconnection and stale previews freezing/recovering for both leading and secondary readers. +- Existing remote, browser, responsive-layout and resource-activity checks. These verify browser behavior, not physical remotes or native rendering engines. + +## Evidence behind the workflow + +- A [multi-screen operator request](https://www.reddit.com/r/VideoProfessionals/comments/10qf346/multiscreen_teleprompter_options/) describes three readers, different iPad sizes showing different passages, and globally mirrored text breaking ordinary monitors. +- [QPrompt #123](https://github.com/Cuperino/QPrompt-Teleprompter/issues/123) asks for a narrower reading column to reduce eye movement. +- [Teleprompter Pro's remote guide](https://guide.teleprompterpro.com/teleprompter/remote-control/) documents one Master, several receiving devices and independent mirroring; it warns about competing Masters on different display sizes. +- [EasyPrompter remote casting](https://easyprompter.com/features/remote-casting) describes independent display layout, a shared position and reconnection. Its latency claims are product descriptions, not measurements of Efir. + +The specific shaded 16:10/16:9 comparison comes from this project's requested +workflow. These sources support the surrounding operator and calibration needs; +they do not establish a quantified market demand for the shading itself. + +The [full research report](research-prompters.md) separates user requests, manufacturer documentation and engineering proposals. diff --git a/docs/research-prompters.md b/docs/research-prompters.md new file mode 100644 index 0000000..701a9eb --- /dev/null +++ b/docs/research-prompters.md @@ -0,0 +1,71 @@ +# Пульты и экраны требуют общей логики + +Исследование выполнено 12 октября 2026 года. Анализ исходной реализации относится к версии `51f225f`; реализованные изменения и их границы описаны в [поддержке пультов](remotes.md) и [работе нескольких экранов](displays.md). + +**Исходный efir совместим с частью клавиатурных пультов, но обещать корректную работу со всеми пультами для суфлёров нельзя.** Фиксированные назначения клавиш, различия обработчиков оператора и читающего экрана и отсутствие защиты от повторного переключения ограничивают даже этот класс устройств. Один сервер уже допускает несколько читающих клиентов, однако общее подключение ещё не обеспечивает одинаковое место чтения при разной вёрстке и точное превью каждого экрана. Приоритет — обучение кнопок, единая обработка ввода, общая позиция сценария и индивидуальные профили дисплеев. Настраиваемые поля, отдельное отражение и наблюдение за несколькими экранами подтверждены реальными запросами пользователей; затемнение разницы между 16:10 и 16:9 — отдельное требование владельца efir. Исследование подготовлено 12 октября 2026 года по исходникам, документации производителей и первичным пользовательским сообщениям. Физические устройства не испытывались. Ниже изложены исследовательские выводы и предложения; состав реализованных функций и результаты программных проверок приведены в документации изменений. + +## Протокол пульта важнее марки на корпусе + +**Bluetooth не означает совместимость с браузерным суфлёром.** Одни устройства работают как обычная клавиатура, другие используют фирменный BLE-сервис, MIDI или управление конкретным приложением. Например, AirTurn PED500 документирует клавиатурные режимы Up/Down, PageUp/PageDown и Space/Enter, отдельный multimedia-режим, AirDirect и MIDI. Номер режима нельзя переносить между моделями: у старого BT-105 назначения отличаются. Поэтому полезнее выбирать «стрелки», «кликер презентаций» или «мультимедийные кнопки», чем обещать универсальный профиль по бренду. ([Инструкция PED500](https://cdn.shopify.com/s/files/1/1109/4160/files/PED500_Manual-English.pdf?v=1732643482), [инструкция BT-105](https://www.craftcms.airturn.com/assets/apps/BT-105-Manual-English.pdf)). + +Следующая матрица описывает **документированный путь интеграции**, а не сертификацию перечисленных моделей в efir. + +| Класс и примеры | Основание совместимости | Необходимая поддержка и граница обещания | +|---|---|---| +| AirTurn PED500 в keyboard mode | Документированы клавиатурные коды | Обучение клавиш; отдельно скорость и перемещение. AirDirect/MIDI требуют другой интеграции | +| Программируемые USB-педали PCsensor | HID-клавиатура/мышь, сохранение назначения в устройстве | Клавиатурные назначения подходят для общего слоя ввода; мышь и multimedia проверяются отдельно. ([Инструкция](https://cdn.pcsensor.com/wp-content/uploads/2025/04/A4-USB%E8%8B%B1%E5%BE%B7-%E8%87%AA%E5%AE%9A%E4%B9%89%E8%84%9A%E8%B8%8F%E5%BC%80%E5%85%B3%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E-V1.pdf)) | +| OM SYSTEM RS27/28/31 H/N | Есть Keyboard mode; по умолчанию включён фирменный режим | Пользователь сначала переключает режим. Учесть разные сигналы нажатия/отпускания. ([Инструкция](https://support.jp.omsystem.com/en/support/imsg/vtrek/download/software/rs27h/pdf/Foot_Switch_Configuration_Tool_Help_win_en_v201.pdf)) | +| 8BitDo Micro | Производитель подтверждает настраиваемый Keyboard mode | Обучение физических кнопок; свойство Micro нельзя распространять на все геймпады. ([FAQ](https://support.8bitdo.com/faq/micro.html)) | +| Презентационные кликеры | Клавиатурный ввод зависит от устройства и настройки | Диагностика и обучение вместо жёсткого предположения PageDown. ([Документация Spotlight](https://support.logi.com/hc/en-150/articles/360023300194-Getting-Started-Spotlight-Presentation-Remote)) | +| Stream Deck / Pedal | Программа изготовителя отправляет hotkey | Поддержать полученные сочетания; учесть фокус и разрешения ОС. Это программная эмуляция. ([Hotkeys](https://www.elgato.com/eu/hu/explorer/products/stream-deck/how-to-set-up-stream-deck-hotkeys/)) | +| Геймпад, доступный браузеру | Gamepad API предоставляет кнопки и оси | Добровольное включение и обучение; проверка фактической доступности, mapping и отключения. ([Спецификация](https://www.w3.org/TR/gamepad/)) | +| NEEWER RT113, Ikan HomeStream | Документация привязывает пульты к приложениям производителей | Пока непроверенные модели; названия кнопок не доказывают DOM-события. ([RT113](https://neewer.com/products/neewer-remote-control-rt111-for-neewer-teleprompter-app-66602183), [HomeStream](https://ikancorp.com/Downloads/HS-PROMPTER-QSG.pdf)) | + +WebHID не решает обычный клавиатурный ввод: Chrome защищает стандартные keyboard/mouse collections от чтения reports. Для клавиатурных пультов нужен KeyboardEvent; нестандартный протокол изучается отдельно. Мультимедийные имена MediaPlayPause и MediaTrackNext стандартизованы, но доставка события зависит от ОС и браузера. Наличие API также не гарантирует доступность Gamepad на конкретном телефоне или встроенном webview. Интерфейс должен сообщать фактический результат обнаружения. ([Chrome WebHID](https://developer.chrome.com/docs/capabilities/hid), [W3C key values](https://www.w3.org/TR/uievents-key/#keys-multimedia)). + +## Обучение кнопок решает подтверждённые проблемы управления + +Пользователь QPrompt прямо просил ручной пульт для изменения скорости и перемещения; автор приложения объяснял переназначение клавиш. Другой пользователь Elgato ищет небольшой кликер для управления скоростью издалека, а владелец NEEWER хочет использовать уже купленный RT113 с другим приложением. Это независимые качественные сигналы в пользу гибких назначений; они не позволяют посчитать популярность конкретной марки. ([QPrompt](https://forum.cuperino.com/t/handheld-remote-controller/171), [запрос кликера](https://www.reddit.com/r/elgato/comments/1o1a7z1/remote_clicker/), [запрос RT113](https://www.reddit.com/r/neewer/comments/1oc140j/rt113_teleprompter_remote_as_a_standard_controller/)). + +**Рекомендуемый сценарий: выбрать действие → нажать кнопку → увидеть сигнал → сохранить.** Нужны понятные действия «пауза/пуск», «быстрее/медленнее», «вперёд/назад», переходы по разделам, диагностика последнего ввода и сброс назначений. Привязки следует сохранять на устройстве, к которому подключён пульт. При отправке настроек текста другим экранам копировать эти привязки не нужно. Обучение должно принимать клавиши и точные сочетания модификаторов, показывать конфликты и захватывать кнопку до выполнения обычной команды. + +В изученном исходном коде оператор и reader имеют разные обработчики, а повторное keydown не фильтруется. Удержание Space способно многократно менять play/pause. Разумное правило: переключение, отражение и переход раздела выполняются один раз за удержание; скорость и перемещение получают управляемое повторение. Общая временная блокировка всех быстрых нажатий испортила бы осознанные двойные клики и некоторые режимы педалей. ([Исходный controller](https://github.com/FedyaLight/efir/blob/51f225f/web/js/controller.js#L601), [исходный reader](https://github.com/FedyaLight/efir/blob/51f225f/web/js/prompter.js#L412), [W3C UI Events](https://www.w3.org/TR/uievents/)). + +Редактор, IME, slider, select и выбранная экранная кнопка сохраняют обычное поведение. Иначе Space одновременно нажмёт кнопку и переключит суфлёр. Удержание прекращается при потере фокуса или отключении устройства. Глобальные горячие клавиши ОС — отдельная функция: browser keydown получает активная страница. Для режима «двигаться, пока держу педаль» нужна отдельная физическая проверка keyup: программируемая педаль способна отправлять короткую пару нажатия/отпускания независимо от длительности удержания. + +## Несколько экранов должны следовать одному месту сценария + +Оператор описал установку из трёх экранов для людей, перемещающихся между ракурсами. Ему требовались единое управление и наблюдение за сценарием с рабочего места. В той же дискуссии другие участники сообщили о расхождении текста на iPad разных размеров и конфликте между отражённым суфлёром через стекло и обычным дополнительным монитором. Перекрёстные публикации исходного запроса считаются одним запросом. ([Обсуждение операторов](https://www.reddit.com/r/VideoProfessionals/comments/10qf346/multiscreen_teleprompter_options/)). + +**Общей должна быть позиция в сценарии; вёрстка принадлежит конкретному экрану.** Одинаковый scrollTop или процент документа не сохраняет место при разной ширине, шрифте и переносах строк. Документация Teleprompter Pro предупреждает о нескольких ведущих устройствах и допускает отдельное отражение; EasyPrompter описывает синхронизацию позиции при индивидуальной типографике. Это подтверждает практику продуктов, но их рекламные заявления о задержке не заменяют измерения. ([Teleprompter Pro](https://guide.teleprompterpro.com/teleprompter/remote-control/), [EasyPrompter](https://easyprompter.com/features/remote-casting)). + +В efir уже существует подходящая основа: session хранит слово и смещение относительно линии чтения. Исследованная архитектура при этом принимает состояние преимущественно primary reader, использует общие настройки и отдельные движки прокрутки. Инженерный вывод — единый источник состояния с позицией слова, смещением, playing, speed и версией сценария; остальные экраны вычисляют собственные пиксельные координаты. Команда пульта на любом reader должна применяться к общей сессии один раз. ([Session](https://github.com/FedyaLight/efir/blob/51f225f/web/js/session.js), [controller](https://github.com/FedyaLight/efir/blob/51f225f/web/js/controller.js), [reader](https://github.com/FedyaLight/efir/blob/51f225f/web/js/prompter.js)). + +Профиль «Камера 1» или «Дальний экран» должен переживать переподключение, но две вкладки одного браузера остаются разными дисплеями. Нужны отдельные имя, поля, шрифт, межстрочный интервал, линия чтения и отражение H/V с наследованием общих значений и явным сбросом. Запрос на узкую центральную колонку подтверждён пользователем QPrompt: широкие строки заставляли двигать глазами. ([QPrompt #123](https://github.com/Cuperino/QPrompt-Teleprompter/issues/123)). + +После обрыва оператор видит «нет связи» и последнее подтверждённое состояние. Анимированное локальное предсказание нельзя выдавать за подтверждение вывода. При восстановлении экран получает актуальную версию сценария и позиции; устаревший ответ не перематывает всех назад. Продолжение движения во время обрыва или остановка — явная политика продукта, а не установленное пожелание большинства пользователей. Изменение полей, resize и поворот сохраняют место чтения. + +## Точное превью и затемнение решают разные задачи + +Каждая карточка должна воспроизводить реальные CSS-размеры и действующие настройки своего reader, затем уменьшаться целиком. Перевёрстка текста под ширину карточки даёт красивое, но ложное превью. Переключение «один экран / все экраны» показывает имя, формат и связь; отдельный выбор «обычный текст / как на мониторе» позволяет проверять отражение. Реконструкция той же сцены предпочтительнее постоянной передачи скриншотов; исторический инженерный отчёт QPrompt описывает затраты покадрового захвата, но не доказывает текущую проблему продукта. ([QPrompt #42](https://github.com/Cuperino/QPrompt-Teleprompter/issues/42)). + +Для 16:10 и 16:9 при одинаковой ширине 1600 высоты равны 1000 и 900. **Затемнение показывает разницу форматов на операторском превью**, сохраняя реальные экраны без автоматического затемнения. Если совмещать линии чтения на 40% высоты, граница меньшего формата на большем проходит от y=40 до y=940: дополнительные 40 пикселей сверху и 60 снизу. Это расчёт предлагаемой геометрии, не внешний эксперимент. Простое центральное совмещение даст по 50 пикселей, но проигнорирует положение линии чтения. + +При разных полях или размерах шрифта геометрическая рамка **не означает «этот текст виден всем»**: переносы строк уже различаются. Подпись должна говорить о сравнении форматов; достоверное сравнение общего текста требует диапазонов видимых слов. Отключённый дисплей не ограничивает общую область без явного выбора оператора. Самостоятельного массового запроса на эту маску не найдено. Кроме того, приложение учитывает viewport, но не исправляет растяжение изображения из-за аппаратного видеорежима или EDID. ([Первичный отчёт о мониторе Ikan 4:3](https://www.reddit.com/r/VIDEOENGINEERING/comments/1btxq00/macbook_pro_cant_drive_a_43_aspect_ratio_for_ikan/)). + +## Приоритет определяется пользой и проверяемостью + +| Приоритет | Предлагаемая граница изменения | Основание и критерий готовности | +|---|---|---| +| P0, отдельный PR | Обучение keyboard/media, общий ввод, защита повторов, диагностика | Прямые запросы пультов; одинаковое действие в операторе и reader, безопасные удержания и редактирование | +| P0, основной экранный PR | Общая позиция, индивидуальные настройки и отражение, точные превью одного/всех экранов, маска форматов | Прямые запросы нескольких дисплеев и владельца; стабильное место чтения при независимой вёрстке | +| P1, вместе с экранным PR либо следом | Имена, восстановление профилей, наследование/копирование, честные статусы и reconnect | Практика продуктов и вывод о надёжности; отсутствие смешения вкладок и отката старым состоянием | +| P1, необязательная часть поддержки ввода | Добровольное Gamepad-обучение | Расширяет протоколы; реальные проверки buttons/axes, потери фокуса и отключения | +| P2, небольшой последующий PR | Общий blackout и blackout одного экрана без потери позиции | Практика EasyPrompter/Pavonine и операторская польза; независимый массовый спрос не доказан. ([Pavonine](https://pavonine.co/teleprompter/)) | +| Отложить до проверки оборудования | Фирменный BLE/MIDI, volume/shutter, системные hotkeys | Нужны отдельные протоколы, разрешения и физические испытания; обучение клавиш этого не гарантирует | + +Готовность экранного PR следует проверять на одном, двух и трёх readers: 16:10 + 16:9, 4:3 + широкий монитор, портретный телефон + планшет. Длинный сценарий, разные поля/шрифты/линии чтения, отражение, seek, замена сценария, resize и продолжительное play должны сохранять общий anchor. Отдельно проверяются потеря ведущего экрана, reconnect, перезагрузка, новые вкладки и устаревшие сообщения. Для пультов программные проверки подтверждают обработчик; список реально проверенных моделей пополняется только испытаниями устройств на конкретных ОС и браузерах. + +## Вывод + +Главный критерий качества — оператор знает, какую команду приняло приложение, где читает каждый спикер и насколько свежо наблюдаемое состояние. Отсюда следуют две самостоятельные задачи: расширяемый ввод с проверяемыми границами совместимости и общая сессия с индивидуальным выводом. Разделять PR полезно по этим границам; выпускать индивидуальные поля раньше надёжной синхронизации нельзя, поскольку они усиливают уже существующий риск расхождения текста. diff --git a/tests/README.md b/tests/README.md index 2bd49e7..51d5a94 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,7 +6,7 @@ do not point them at a controller containing real scripts. ## HTTP, WebSocket and browser behavior -Start either harness on a separate port. Both import the production server: +Start either harness on a separate port. Both import the production server. Run browser suites sequentially or give each suite its own server port: the native server has one shared session regardless of room code. ```sh swift run --package-path tests/ServerHarness --scratch-path work/server-build \ @@ -28,6 +28,7 @@ EFIR_URL=http://localhost:18765 EFIR_LAN_URL=http://YOUR_WIFI_IP:18765 \ node tests/performance.mjs EFIR_URL=http://localhost:18765 node tests/layout.mjs EFIR_URL=http://localhost:18765 node tests/remotes.mjs +EFIR_URL=http://localhost:18765 node tests/displays.mjs node tests/hosting.mjs ``` @@ -40,6 +41,7 @@ On macOS, `http://0.0.0.0:18765` can provide an insecure Chromium context for | `browser.mjs` | Sync, preview, reconnect, tap playback, mirror, voice, languages, backups, HTTP screen protection | | `hosting.mjs` | Static hosting without marker, real PeerJS broker, delayed cross-profile WebRTC, established link during broker loss | | `performance.mjs` | Actual frame, storage, transport and main-thread activity in Chromium | +| `displays.mjs` | Three readers with different wraps, format shading, actual preview geometry, targeted calibration, duplicate links, seeking, reload, rotation, leader failover and stale preview recovery | | `remotes.mjs` | Held key edges, modified keys, button learning, saved mappings, editing/dialog guards, nudge release, browser Gamepad signals | | `layout.mjs` | Six UI languages, light/dark, narrow/landscape/tablet/desktop, dialogs, icon-only exit and fullscreen labels | diff --git a/tests/browser.mjs b/tests/browser.mjs index d599c38..3065290 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -28,7 +28,7 @@ async function waitFor(fn, detail, ms = 8000) { while (Date.now() - start < ms) { if (await fn()) return; await new Promise(r => setTimeout(r, 60)); } throw new Error('timeout: ' + detail); } -const cy = async page => page.locator('.scroller').evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m42); +const cy = async page => page.locator('.preview-primary .scroller, .p-stage .scroller').evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m42); try { const mc = await context(true), pc = await context(); const controller = await mc.newPage(), phone = await pc.newPage(); @@ -49,9 +49,12 @@ try { await phone.locator('.p-stage').tap(); await waitFor(async () => await phone.locator('.prompter:not(.playing):not(.ui-hidden)').count() > 0, 'reader tap pauses and reveals controls'); await phone.locator('[data-a="mirror"]').tap(); - await waitFor(async () => await controller.evaluate(async () => (await import('./js/store.js')).store.settings().mirrorH === true), 'reader mirror reaches controller settings'); + await waitFor(async () => await controller.evaluate(async () => Object.values((await import('./js/store.js')).store.displayProfiles()).some(p => p.overrides.mirrorH === true)), 'reader mirror reaches its display profile'); await phone.locator('[data-a="mirror"]').tap(); - await waitFor(async () => await controller.evaluate(async () => (await import('./js/store.js')).store.settings().mirrorH === false), 'reader mirror toggles off'); + await waitFor(async () => await controller.evaluate(async () => Object.values((await import('./js/store.js')).store.displayProfiles()).some(p => p.overrides.mirrorH === false)), 'reader mirror profile toggles off'); + await phone.locator('[data-a="display"]').tap(); + await phone.locator('[data-display-reset]').click(); + await phone.locator('.modal-x').click(); await phone.locator('.modal-wrap').waitFor({ state: 'detached' }); const fontSize = controller.locator('[data-k="fontSize"] input'); await fontSize.fill('44'); await fontSize.dispatchEvent('input'); await waitFor(async () => await phone.locator('.text').evaluate(el => getComputedStyle(el).fontSize) === '44px', 'settings sync'); diff --git a/tests/displays.mjs b/tests/displays.mjs new file mode 100644 index 0000000..d20a345 --- /dev/null +++ b/tests/displays.mjs @@ -0,0 +1,134 @@ +// Multiple real readers: geometry, semantic sync, profiles and lifecycle. +import { chromium } from '../work/testing/node_modules/playwright/index.mjs'; +import assert from 'node:assert/strict'; +const base = process.env.EFIR_URL || 'http://localhost:18768'; +const browser = await chromium.launch({ headless: true }); +const errors = []; +const wait = async (fn, message, ms = 10000) => { + const end = Date.now() + ms; + while (Date.now() < end) { if (await fn()) return; await new Promise(r => setTimeout(r, 60)); } + throw new Error(message); +}; +const offset = (page, selector = '.p-stage .scroller') => page.locator(selector).evaluate(el => -new DOMMatrix(getComputedStyle(el).transform).m42); +// Reading progress measured from displayed words, without importing session logic. +const progress = page => page.locator('.p-stage .stage').evaluate(stage => { + const pos = -new DOMMatrix(getComputedStyle(stage.querySelector('.scroller')).transform).m42; + const marker = parseFloat(stage.style.getPropertyValue('--my')); + const words = [...stage.querySelectorAll('.w')]; + const lines = words.filter((w, i) => !i || w.offsetTop !== words[i - 1].offsetTop); + const after = lines.findIndex(w => w.offsetTop + w.offsetHeight / 2 > pos + marker); + const b = lines[after < 0 ? lines.length - 1 : after], a = lines[Math.max(0, (after < 0 ? lines.length - 1 : after) - 1)]; + const ay = a.offsetTop + a.offsetHeight / 2, by = b.offsetTop + b.offsetHeight / 2; + const f = by === ay ? 0 : (pos + marker - ay) / (by - ay); + return Number(a.dataset.i) + f * (Number(b.dataset.i) - Number(a.dataset.i)); +}); +async function profile(page, button, changes) { + await button.click(); + for (const [key, value] of Object.entries(changes)) { + if (key === 'name') { await page.locator('.display-name').fill(value); continue; } + const row = page.locator(`[data-display-field="${key}"]`); + await row.locator('.display-own').check(); + if (typeof value === 'boolean') await row.locator('.display-value').setChecked(value); + else { await row.locator('.display-value').fill(String(value)); await row.locator('.display-value').dispatchEvent('change'); } + } + await page.locator('.modal-x').click(); await page.locator('.modal-wrap').waitFor({ state: 'detached' }); +} +try { + const context = async (width, height, controller = false) => { + const c = await browser.newContext({ locale: 'ru-RU', viewport: { width, height } }); + if (controller) await c.addInitScript(() => { window.efirNative = { version: 1, platform: 'linux', role: 'controller', capabilities: {}, postMessage() {} }; }); + const p = await c.newPage(); p.on('pageerror', e => errors.push(e.message)); return p; + }; + const ctl = await context(1500, 1000, true), a = await context(1600, 1000), b = await context(1600, 900); + await ctl.goto(base + '/#/c/MULTI'); await a.goto(base + '/?screen=wideA#/p/MULTI'); + await a.locator('.prompter.has-script.has-ctl').waitFor(); + await b.goto(base + '/?screen=wideB#/p/MULTI'); await b.locator('.prompter.has-script.has-ctl').waitFor(); + await wait(async () => await ctl.locator('.pm-screen option').count() === 2, 'two selectable screens'); + const mask = await ctl.locator('.preview-primary .format-region').evaluate(el => { + const scale = new DOMMatrix(getComputedStyle(el.parentElement.querySelector('.preview-scale')).transform).a; + return { hidden: el.hidden, top: parseFloat(el.style.top) / scale, height: parseFloat(el.style.height) / scale }; + }); + assert.equal(mask.hidden, false); assert.ok(Math.abs(mask.top - 40) < .1, '16:10 excess begins 40px above reading-aligned 16:9'); + assert.ok(Math.abs(mask.height - 900) < .1, '16:9 comparison height is 900px'); + const duplicate = await context(1200, 800); await duplicate.goto(b.url()); + await duplicate.locator('.prompter.has-script.has-ctl').waitFor(); + await wait(async () => duplicate.url() !== b.url(), 'duplicate display link receives a distinct identity'); + await duplicate.close(); await ctl.reload(); await ctl.locator('.ctl.has-prompter').waitFor(); + const portrait = await context(500, 900); await portrait.goto(base + '/?screen=portrait#/p/MULTI'); + await portrait.locator('.prompter.has-script.has-ctl').waitFor(); + await wait(async () => await ctl.locator('.pm-screen option').count() === 3, 'three screens'); + await ctl.locator('button[data-view="edit"]').first().click(); + const script = Array.from({ length: 2600 }, (_, i) => 'word' + String(i).padStart(5, '0')).join(' '); + await ctl.locator('.ed-text').fill(script); await ctl.locator('button[data-view="live"]').first().click(); + await ctl.locator('[data-k="fontSize"] input').fill('32'); await ctl.locator('[data-k="fontSize"] input').dispatchEvent('input'); + await ctl.locator('[data-k="fontFamily"] select').selectOption('JetBrains Mono'); + await ctl.locator('[data-k="countdown"] [data-v="0"]').click(); + await wait(async () => await portrait.locator('.w').count() === 2600 && await a.locator('.w').count() === 2600, 'chunked script reaches all screens'); + const card = id => ctl.locator(`[data-screen-id="${id}"]`); + await profile(ctl, card('portrait').locator('.screen-config'), { name: 'Камера 3', leftPad: 3, rightPad: 15, mirrorH: true }); + await wait(async () => await portrait.locator('.stage').evaluate(el => el.style.getPropertyValue('--left')) === '15px', 'targeted left margin'); + assert.equal(await b.locator('.stage').evaluate(el => el.style.getPropertyValue('--left')), '128px', 'other screen margins unchanged'); + assert.ok(await portrait.locator('.stage-inner').evaluate(el => getComputedStyle(el).transform.includes('-1'))); + assert.equal(await b.locator('.stage-inner').evaluate(el => getComputedStyle(el).transform), 'none', 'mirror is screen-local'); + await b.setViewportSize({ width: 1600, height: 1000 }); + await profile(ctl, card('wideB').locator('.screen-config'), { fontSize: 48, sidePad: 15 }); + await wait(async () => await b.locator('.stage').evaluate(el => el.style.getPropertyValue('--fs')) === '48px', 'different layout on same size viewport'); + await ctl.locator('[data-a="start"]').click(); await new Promise(r => setTimeout(r, 500)); + await ctl.locator('.speed-range').fill('10'); await ctl.locator('.speed-range').dispatchEvent('input'); + await ctl.locator('[data-a="toggle"]').click(); + await wait(async () => await a.locator('.prompter.playing').count() === 1 && await portrait.locator('.prompter.playing').count() === 1, 'all readers play'); + await new Promise(r => setTimeout(r, 14000)); + const places = await Promise.all([progress(a), progress(b), progress(portrait)]); + assert.ok(places[0] > 100, 'long playback advances across many lines'); + assert.ok(Math.max(...places) - Math.min(...places) < 15, 'different wraps stay at the same passage: ' + places); + await ctl.locator('[data-a="toggle"]').click(); await new Promise(r => setTimeout(r, 250)); + for (const [id, reader] of [['wideB', b], ['portrait', portrait]]) { + const preview = card(id).locator('.stage'); + assert.deepEqual(await preview.evaluate(el => [el.offsetWidth, el.offsetHeight]), await reader.locator('.stage').evaluate(el => [el.offsetWidth, el.offsetHeight])); + const discrepancy = Math.abs(await offset(ctl, `[data-screen-id="${id}"] .scroller`) - await offset(reader)); + assert.ok(discrepancy < 3, 'individual preview matches actual screen: ' + discrepancy); + } + await ctl.locator('[data-a="toggle"]').click(); + await b.context().setOffline(true); + await wait(async () => await card('wideB').locator('.screen-ready').innerText() === 'Нет свежих данных — последний кадр', 'stale preview is explicit', 6000); + const frozen = await offset(ctl, '[data-screen-id="wideB"] .scroller'); + await new Promise(r => setTimeout(r, 250)); + assert.equal(await offset(ctl, '[data-screen-id="wideB"] .scroller'), frozen, 'stale preview does not invent motion'); + await b.context().setOffline(false); + await wait(async () => await card('wideB').locator('.screen-ready').innerText() !== 'Нет свежих данных — последний кадр' && await b.locator('.prompter.playing').count() === 1, 'stale display recovers', 12000); + await a.context().setOffline(true); + await wait(async () => await ctl.locator('.device-ready').innerText() === 'Нет свежих данных — последний кадр', 'stale leading preview is explicit', 6000); + const leadingFrozen = await offset(ctl, '.preview-primary .scroller'); + await new Promise(r => setTimeout(r, 250)); + assert.equal(await offset(ctl, '.preview-primary .scroller'), leadingFrozen, 'stale leading preview freezes'); + await a.context().setOffline(false); + await wait(async () => await ctl.locator('.device-ready').innerText() !== 'Нет свежих данных — последний кадр' && await a.locator('.prompter.playing').count() === 1, 'leading display recovers', 12000); + await ctl.locator('[data-a="toggle"]').click(); await new Promise(r => setTimeout(r, 250)); + await ctl.locator('.pm-all').uncheck(); assert.equal(await ctl.locator('.screen-cards').isVisible(), false); + await ctl.locator('.pm-all').check(); + await portrait.locator('.p-stage').dispatchEvent('wheel', { deltaY: 500 }); + await new Promise(r => setTimeout(r, 800)); + const sought = await Promise.all([progress(a), progress(b), progress(portrait)]); + assert.ok(Math.max(...sought) - Math.min(...sought) < 15, 'follower wheel seeks the whole session: ' + sought); + const beforeReload = portrait.url(); await portrait.reload(); await portrait.locator('.prompter.has-script.has-ctl').waitFor(); + assert.equal(portrait.url(), beforeReload, 'stable screen link'); + await wait(async () => await portrait.locator('.stage').evaluate(el => el.style.getPropertyValue('--left')) === '15px', 'profile survives reload'); + await wait(async () => await ctl.locator('.screen-card').count() === 2, 'reconnect replaces its card'); + await b.setViewportSize({ width: 900, height: 1600 }); + await wait(async () => await card('wideB').locator('.stage').evaluate(el => el.offsetHeight) === 1600, 'resized follower preview'); + await ctl.locator('[data-a="toggle"]').click(); + await a.close(); + await wait(async () => await ctl.locator('.pm-screen option').count() === 2, 'leading screen disconnects'); + await wait(async () => await b.locator('.prompter.playing').count() === 1 && await portrait.locator('.prompter.playing').count() === 1, 'playback continues after failover'); + await new Promise(r => setTimeout(r, 2500)); + assert.ok(Math.abs(await progress(b) - await progress(portrait)) < 15, 'new leader stays in sync'); + const lost = await ctl.locator('.pm-screen').inputValue(); + await ctl.locator('.pm-screen').selectOption({ index: 1 }); + assert.notEqual(await ctl.locator('.pm-screen').inputValue(), lost, 'manual leader selection'); + await ctl.locator('[data-a="toggle"]').click(); await new Promise(r => setTimeout(r, 250)); + await b.close(); await wait(async () => (await ctl.locator('.screen-ready').allTextContents()).some(s => s.includes('последний кадр')), 'disconnected display is labelled'); + await ctl.reload(); await ctl.locator('.ctl.has-prompter').waitFor(); + await wait(async () => (await ctl.locator('.pm-screen').innerText()).includes('Камера 3'), 'controller restores display name/profile'); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ result: 'PASS', playbackWordPositions: places, checked: ['16:10/16:9 shaded regions', 'three readers', 'duplicate display link', 'different fonts on equal viewports', 'semantic sync across wraps', 'exact individual previews', 'per-screen margins/mirror', 'follower seek', 'reload', 'resize', 'leader failover/selection', 'offline preview', 'stale preview freezes and recovers', 'controller profile recovery'] })); +} finally { await browser.close(); } diff --git a/web/css/app.css b/web/css/app.css index ee11b05..0a1566b 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -253,7 +253,7 @@ kbd { font-family: var(--mono); font-size: 12px; padding: 2px 7px; border-radius .scroller { position: absolute; left: 0; right: 0; top: 0; will-change: transform; } .text { position: relative; - padding: var(--top) var(--side) var(--bottom); + padding: var(--top) var(--right, var(--side)) var(--bottom) var(--left, var(--side)); font-size: var(--fs); font-weight: var(--fw); line-height: var(--line, var(--lh)); letter-spacing: var(--ls); text-align: var(--align); overflow-wrap: break-word; word-break: normal; @@ -719,3 +719,29 @@ kbd { font-family: var(--mono); font-size: 12px; padding: 2px 7px; border-radius .remote-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--rule); align-items: center; } .remote-bindings { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 10px; } .remote-bindings .link-btn { max-width: 100%; overflow-wrap: anywhere; } + +/* Independent monitor previews share the reader's exact CSS geometry. */ +.screen-cards { display: contents; } +.screen-cards[hidden], .screen-card[hidden] { display: none; } +.preview-box.multi { grid-template-columns: repeat(var(--preview-cols, 2), minmax(0, 1fr)); align-content: start; align-items: start; gap: 12px; overflow: auto; } +.preview-box.multi .monitor { width: 100%; min-width: 0; align-items: center; } +.preview-box.multi .mv-label { width: 100%; } +.screen-canvas { display: flex; justify-content: center; align-items: flex-start; width: 100%; } +.screen-frame { position: relative; overflow: hidden; background: #000; } +.screen-label { width: 100%; font-size: 11px; color: var(--screen-ink); overflow-wrap: anywhere; } +.screen-ready { width: 100%; font-size: 10px; line-height: 1.4; color: #b5b5ad; } +.screen-config { color: #d6d5cf; font-size: 11px; } +.screen-card:is(.offline,.stale) .screen-canvas, .preview-primary.stale .screen-canvas { opacity: .5; } +.format-region { position: absolute; pointer-events: none; border: 1px solid rgba(255, 191, 91, .6); box-shadow: 0 0 0 10000px rgba(0, 0, 0, .26); } +.format-note { font-size: 11px; line-height: 1.4; color: var(--muted); flex: none; } +.leading-label { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font-size: 12px; } +.pm-screen { max-width: 180px; min-width: 100px; font-size: 12px; height: 30px; } +.display-field { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--rule); } +.display-field .chk { white-space: normal; } +.display-value[type="number"] { width: 85px; padding: 6px; } +.display-name { max-width: 220px; min-width: 0; padding: 6px; } +.display-fields { margin: 12px 0 18px; } +@media (max-width: 600px) { + .p-top-r [data-a="display"] { display: none; } + .preview-meta { gap: 8px; } +} diff --git a/web/js/controller.js b/web/js/controller.js index 18e2b63..8fa02e2 100644 --- a/web/js/controller.js +++ b/web/js/controller.js @@ -1,9 +1,11 @@ import { t, translateUI, onLanguageChange, languagePicker, countLabel } from './i18n.js'; +import { cleanDisplayProfile, displaySettings } from './display-profile.js'; +import { DisplayViews, screenId, profileDialog, showFormatRegion } from './display-views.js'; import { mountRemotes } from './remotes.js'; import { FrameLoop } from './frames.js'; import { localMode, nativeVoice, hostName } from './environment.js'; import { platform } from './platform.js'; -import { captureSession, restoreSession } from './session.js'; +import { captureSession, restoreSession, sessionPosition } from './session.js'; // Controller: library, editor, preview and settings. Its engine follows // the reading screen, preserving matching layout and smooth scrolling. @@ -19,6 +21,7 @@ import { icon, toast, qrSvg, modal, closeModals, loadScript, roomUrl, copyText } const LAYOUT_KEYS = ['fontSize', 'fontFamily', 'fontWeight', 'lineHeight', 'letterSpacing', 'uppercase', 'sidePad', 'topPad', 'bottomPad', 'markerPos', 'align']; const FORMATS = [ + { id: '1600x1000', label: '16:10 · монитор', w: 1600, h: 1000 }, { id: '1280x720', label: '16:9 · монитор', w: 1280, h: 720 }, { id: '1180x820', label: 'iPad горизонтально', w: 1180, h: 820 }, { id: '820x1180', label: 'iPad вертикально', w: 820, h: 1180 }, @@ -114,6 +117,12 @@ export function mountController(root, code) { let sessionEstablished = false; let lastTranscript = ''; let lastNativeActive = null; + const latest = new Map(), seen = new Map(), profiles = store.displayProfiles(); + let primaryStale = false; + let views, commandTag = '', lastFollow = 0; + let allScreens = localStorage.getItem('efir.allScreens') !== 'false'; + let compareFormats = localStorage.getItem('efir.compareFormats') !== 'false'; + let preferredScreen = localStorage.getItem('efir.leadingScreen'); const pendingSync = new Map(), readiness = new Map(); const readerActions = platform.has('showPrompter') ? `
${t('Без отдельной настройки экран использует общие значения с пульта.')}
+ `); + const fields = dialog.el.querySelector('.display-fields'); + function render() { + const effective = displaySettings(settings, current); + fields.innerHTML = Object.entries(DISPLAY_FIELDS).map(([key, [name, min, max]]) => { + const own = Object.hasOwn(current.overrides, key), value = effective[key] ?? effective.sidePad; + return `