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') ? `
@@ -161,18 +170,25 @@ export function mountController(root, code) {
-
-
+
+
+
тяните текст, чтобы прокрутить
-
+
+
+ + + +
+
${readerActions} @@ -261,18 +277,51 @@ export function mountController(root, code) { let scale = 1; function fitPreview() { const [vw, vh] = viewport(); + const ps = room.peersOf('prompter'); + const selected = ps.find(p => p.id === primary); + const effective = displaySettings(settings, selected && profileFor(selected)); + if (JSON.stringify(stage.s) !== JSON.stringify(effective)) relayout(stage, engine, () => stage.setSettings(effective)); stage.setViewport(vw, vh); - const bw = box.clientWidth, bh = box.clientHeight - 30; - if (!bw || !bh) return; + views?.sync(ps, primary, settings, cur.text, showMirror); + const count = allScreens ? 1 + (views?.records.size || 0) : 1; + const multi = allScreens && count > 1; + box.classList.toggle('multi', multi); + $('.screen-cards').hidden = !multi; + views?.setActive(multi && el.dataset.view === 'live'); + const cols = multi && box.clientWidth >= 380 ? 2 : 1; + box.style.setProperty('--preview-cols', cols); + const bw = (box.clientWidth - (cols - 1) * 12) / cols - 12; + const cellHeight = Math.max(multi ? 190 : 120, box.clientHeight / Math.ceil(count / cols)); + const bh = cellHeight - (multi ? 70 : 30); + if (bw <= 0 || bh <= 0) return; scale = Math.min(bw / vw, bh / vh); frame.style.width = vw * scale + 'px'; frame.style.height = vh * scale + 'px'; + $('.preview-primary .screen-canvas').style.height = multi ? Math.max(1, cellHeight - 70) + 'px' : vh * scale + 'px'; scaleHost.style.transform = `scale(${scale})`; const p = primaryInfo(); $('.pm-dev').innerHTML = p - ? `${escapeHtml(p.name || t('Суфлёр'))}${vw}×${vh}` + ? `${escapeHtml(selected ? profileFor(selected).name || p.name || t('Суфлёр') : p.name || t('Суфлёр'))}${vw}×${vh}` : `${t('Нет суфлёра')}${vw}×${vh}`; el.classList.toggle('has-prompter', !!p); + const others = views?.stages().filter(s => s !== views.records.get(screenId(selected))?.stage) || []; + showFormatRegion(frame.querySelector('.format-region'), stage, others, scale, compareFormats, showMirror); + views?.fit(bw + 12, cellHeight, [stage, ...others], compareFormats); + $('.format-note').hidden = !compareFormats || ps.length < 2; + const options = ps.map(p => ({ id: p.id, label: profileFor(p).name || p.hello?.name || t('Суфлёр') })); + const select = $('.pm-screen'); + const key = JSON.stringify(options); + if (select.dataset.options !== key) { select.dataset.options = key; select.innerHTML = options.map(p => ``).join(''); } + select.value = primary || ''; + select.disabled = !ps.length; + $('[data-a="display"]').disabled = !selected; + refreshPrimaryPreview(); + } + function refreshPrimaryPreview() { + const stale = !!primary && seen.has(primary) && performance.now() - seen.get(primary) > 3500; + if (stale !== primaryStale) { primaryStale = stale; renderReady(); } + $('.preview-primary').classList.toggle('stale', stale); + frames?.setActive(el.dataset.view === 'live' && !stale); } const previewObserver = new ResizeObserver(fitPreview); previewObserver.observe(box); @@ -292,18 +341,43 @@ export function mountController(root, code) { $('.pm-fmt').value = fmtId; $('.pm-fmt').onchange = (e) => { fmtId = e.target.value; localStorage.setItem('efir.fmt', fmtId); relayout(stage, engine, fitPreview); }; - $('.pm-mirror').onchange = (e) => { showMirror = e.target.checked; stage.setShowMirror(showMirror); }; + $('.pm-mirror').onchange = (e) => { showMirror = e.target.checked; stage.setShowMirror(showMirror); fitPreview(); }; + $('.pm-all').checked = allScreens; + $('.pm-compare').checked = compareFormats; + $('.pm-all').onchange = e => { allScreens = e.target.checked; localStorage.setItem('efir.allScreens', allScreens); fitPreview(); }; + $('.pm-compare').onchange = e => { compareFormats = e.target.checked; localStorage.setItem('efir.compareFormats', compareFormats); fitPreview(); }; + $('.pm-screen').onchange = e => { + primary = e.target.value; remote = latest.get(primary) || null; + const peer = room.peersOf('prompter').find(p => p.id === primary); + preferredScreen = screenId(peer); localStorage.setItem('efir.leadingScreen', preferredScreen); + relayout(stage, engine, fitPreview); + if (remote) restoreSession(stage, engine, remote); + configureFollowers(); renderReady(); frames?.request(); + }; // Networking const room = new Room(code, 'controller', () => ({ name: platform.native ? platform.name : t('Пульт') })); + views = new DisplayViews($('.screen-cards'), { configure: configureDisplay, profile: profileFor, + ready: p => readyText(readiness.get(p.id) || p.hello?.ready) }); + function profileFor(peer) { return cleanDisplayProfile(profiles[screenId(peer)] || peer?.hello?.profile); } + function updateDisplay(peer, profile) { + if (!peer) return; + const id = screenId(peer); profiles[id] = cleanDisplayProfile(profile); store.saveDisplayProfile(id, profiles[id]); + room.send('display', { to: peer.id, profile: profiles[id] }); + relayout(stage, engine, fitPreview); renderStatus(); frames?.request(); + } + function configureDisplay(peer) { if (peer) profileDialog(profileFor(peer), settings, profile => updateDisplay(peer, profile)); } + function configureFollowers() { room.send('follow', { leader: primary }); } function pickPrimary() { const ps = room.peersOf('prompter'); - if (primary && ps.some(p => p.id === primary)) return; + const visible = ps.filter(p => (readiness.get(p.id) || p.hello?.ready)?.visible !== false); + const available = visible.length ? visible : ps; + if (primary && available.some(p => p.id === primary)) return; const prev = primary; - primary = ps.length ? ps[0].id : null; - remote = null; - if (prev !== primary) relayout(stage, engine, fitPreview); + primary = (available.find(p => screenId(p) === preferredScreen) || available[0])?.id || null; + remote = latest.get(primary) || null; + if (prev !== primary) { relayout(stage, engine, fitPreview); if (remote) restoreSession(stage, engine, remote); configureFollowers(); frames?.request(); } } function renderStatus() { @@ -335,7 +409,10 @@ export function mountController(root, code) { room.addEventListener('link', renderStatus); room.addEventListener('join', (e) => { if (e.detail.role === 'prompter') { - const offered = room.peersOf('prompter').find(p => p.id === e.detail.id)?.hello?.session; + const peer = room.peersOf('prompter').find(p => p.id === e.detail.id); + const offered = peer?.hello?.session; + const id = screenId(peer); + if (id && !profiles[id]) { profiles[id] = cleanDisplayProfile(peer?.hello?.profile); store.saveDisplayProfile(id, profiles[id]); } const sid = cur.id + ':' + cur.updated; const initial = !sessionEstablished && offered?.sid === sid ? offered : captureSession(stage, engine, sid); pickPrimary(); @@ -348,9 +425,10 @@ export function mountController(root, code) { sessionEstablished = true; // Send a new reader the settings, script and session. const tag = uid(); pendingSync.set(e.detail.id, tag); - room.send('settings', { s: settings, to: e.detail.id }); + room.send('settings', { s: settings, profile: profileFor(peer), to: e.detail.id }); sendScript(e.detail.id); room.send('session', { to: e.detail.id, tag, state: { ...initial, voiceState: localVoiceState } }); + configureFollowers(); if (!e.detail.again) toast(t('Суфлёр подключён'), 'ok'); fitPreview(); } @@ -358,19 +436,25 @@ export function mountController(root, code) { }); room.addEventListener('leave', (e) => { if (e.detail.role === 'prompter') { - pendingSync.delete(e.detail.id); readiness.delete(e.detail.id); + pendingSync.delete(e.detail.id); readiness.delete(e.detail.id); latest.delete(e.detail.id); seen.delete(e.detail.id); if (e.detail.id === primary) { primary = null; pickPrimary(); fitPreview(); toast(t('Суфлёр отключился')); } } - renderStatus(); + fitPreview(); renderStatus(); }); let lastScriptPush = 0; room.addEventListener('message', (e) => { const m = e.detail; if (m.role !== 'prompter') return; - if (m.t === 'control' && (m.c === 'play' || m.c === 'pause')) { cmd(m.c); return; } - if (m.t === 'mirror' && typeof m.value === 'boolean') { set('mirrorH', m.value); return; } - if (m.t === 'readiness') { readiness.set(m.from, m.ready); renderReady(); if (connectModal) renderConnectDevices(); return; } + if (m.t === 'control') { + if (m.c === 'seek' && m.state?.sid === cur.id + ':' + cur.updated) seekRemote(sessionPosition(stage, m.state), !!m.smooth); + else if (m.c === 'nudge' && Number.isFinite(m.lines)) cmd('nudge', { v: m.lines * stage.s.fontSize * stage.s.lineHeight }); + else if (['play', 'pause', 'start', 'section', 'speed'].includes(m.c)) cmd(m.c, m); + return; + } + if (m.t === 'mirror' && typeof m.value === 'boolean') { const peer = room.peersOf('prompter').find(p => p.id === m.from); const profile = profileFor(peer); updateDisplay(peer, { ...profile, overrides: { ...profile.overrides, mirrorH: m.value } }); return; } + if (m.t === 'displayChange') { updateDisplay(room.peersOf('prompter').find(p => p.id === m.from), m.profile); return; } + if (m.t === 'readiness') { readiness.set(m.from, m.ready); pickPrimary(); fitPreview(); renderReady(); if (connectModal) renderConnectDevices(); return; } if (m.t === 'transcript' && m.from === primary && settings.voiceDevice === 'prompter') { showTranscript(m.text); return; } if ((m.t === 'hello' || m.t === 'presence') && m.from === primary) { const [vw, vh] = [stage.w, stage.h]; @@ -381,14 +465,24 @@ export function mountController(root, code) { if (pendingSync.get(m.from) !== m.sync) return; pendingSync.delete(m.from); } + const peer = room.peersOf('prompter').find(p => p.id === m.from); + if (!peer) return; + if (m.sid === cur.id + ':' + cur.updated) { + const prior = latest.get(m.from); latest.set(m.from, m); seen.set(m.from, performance.now()); views.state(peer, m); + if (!prior || prior.vw !== m.vw || prior.vh !== m.vh) fitPreview(); + } else if (performance.now() - lastScriptPush > 2500) { sendScript(m.from); } if (!primary) pickPrimary(); if (m.from !== primary) return; if (m.sid !== cur.id + ':' + cur.updated) { if (performance.now() - lastScriptPush > 2500) sendScript(m.from); return; } const sizeChanged = !remote || remote.vw !== m.vw || remote.vh !== m.vh; remote = m; + refreshPrimaryPreview(); + const now = performance.now(); + if (m.sid === cur.id + ':' + cur.updated && (!commandTag || m.command === commandTag || now > Math.max(holdUntil, seekHold)) && now - lastFollow >= 45) { + lastFollow = now; room.send('followState', { leader: primary, state: m }); + } if (sizeChanged) relayout(stage, engine, fitPreview); - const now = performance.now(); if (now > holdUntil) { engine.playing = m.playing; engine.speed = m.speed; @@ -429,9 +523,9 @@ export function mountController(root, code) { function cmd(c, m = {}) { sessionEstablished = true; if ((c === 'toggle' || c === 'pause') && engine.mode === 'voice') { voiceToggle(); return; } + if (c === 'toggle') c = engine.playing ? 'pause' : 'play'; holdUntil = performance.now() + 450; switch (c) { - case 'toggle': engine.toggle(settings.countdown); break; case 'play': engine.play(settings.countdown); break; case 'pause': engine.pause(); break; case 'start': engine.pause(); seekLocal(0, true); m = {}; break; @@ -440,10 +534,12 @@ export function mountController(root, code) { case 'section': { const p = m.k != null ? stage.posForSection(m.k) : stage.sectionStep(engine.pos, m.dir); seekLocal(p, true); + c = 'seek'; m = { pos: p, word: stage.wordAt(p), vw: stage.w, vh: stage.h, state: positionState(p), smooth: true }; break; } } - room.send('cmd', { c, ...m }); + commandTag = uid(); + room.send('cmd', { ...m, c, tag: commandTag }); frames?.request(); updateTransport(true); } @@ -458,12 +554,13 @@ export function mountController(root, code) { } frames?.request(); } + function positionState(pos) { return captureSession(stage, { pos, snapshot: () => ({ ...engine.snapshot(), pos }) }, cur.id + ':' + cur.updated); } let seekSendT = 0, seekPending = null; function seekRemote(p, smooth = false) { sessionEstablished = true; seekLocal(p, smooth); const cp = stage.clamp(p); - seekPending = { c: 'seek', pos: cp, word: stage.wordAt(cp), vw: stage.w, vh: stage.h, smooth }; + seekPending = { c: 'seek', pos: cp, word: stage.wordAt(cp), vw: stage.w, vh: stage.h, state: positionState(cp), smooth }; const now = performance.now(); if (now - seekSendT > 30) flushSeek(); else setTimeout(flushSeek, 30); @@ -471,7 +568,7 @@ export function mountController(root, code) { function flushSeek() { if (!seekPending) return; seekSendT = performance.now(); - room.send('cmd', seekPending); + commandTag = uid(); room.send('cmd', { ...seekPending, tag: commandTag }); seekPending = null; } @@ -539,6 +636,7 @@ export function mountController(root, code) { else if (a === 'connect') { if (!platform.has('standalone')) openConnect(); } else if (a === 'read') platform.send('showPrompter'); else if (a === 'mirror') set('mirrorH', !settings.mirrorH); + else if (a === 'display') configureDisplay(room.peersOf('prompter').find(p => p.id === primary)); else if (a === 'window') openWindow(); else if (a === 'keys') openKeys(); else if (a === 'new') addScript(newScript(), true); @@ -578,7 +676,7 @@ export function mountController(root, code) { }); frame.addEventListener('pointermove', (e) => { if (!drag || drag.id !== e.pointerId) return; - const dy = (e.clientY - drag.y) / scale * (showMirror && settings.mirrorV ? -1 : 1); + const dy = (e.clientY - drag.y) / scale * (showMirror && stage.s.mirrorV ? -1 : 1); if (!drag.moved && Math.abs(dy) * scale > 4) { drag.moved = true; frame.classList.add('dragging'); } if (drag.moved) seekRemote(drag.pos - dy); }); @@ -614,8 +712,10 @@ export function mountController(root, code) { // Tabs function setView(v) { el.dataset.view = v; + refreshPrimaryPreview(); + if (v !== 'live') views?.setActive(false); el.querySelectorAll('[data-view]').forEach(b => { if (b.tagName === 'BUTTON') b.classList.toggle('on', b.dataset.view === v); }); - if (v === 'live') requestAnimationFrame(fitPreview); + if (v === 'live') requestAnimationFrame(() => { stage.invalidate(); fitPreview(); if (remote) restoreSession(stage, engine, remote); frames?.request(); }); if (v === 'edit') syncEditor(); } el.querySelectorAll('button[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view))); @@ -666,7 +766,7 @@ export function mountController(root, code) { if (engine.mode === 'voice') { engine.voiceIdx = 0; tracker.setPosition(0); } sendScript(); room.send('cmd', { c: 'seek', pos: 0, word: 0, vw: stage.w, vh: stage.h }); - renderLib(); renderSections(); syncEditor(); + renderLib(); renderSections(); syncEditor(); fitPreview(); } function addScript(s, edit) { scripts.push(s); @@ -811,7 +911,7 @@ export function mountController(root, code) { edTimer = setTimeout(() => { relayout(stage, engine, () => stage.setScript(cur.text)); tracker.setWords(stage.words); - renderSections(); renderStats(); + renderSections(); renderStats(); fitPreview(); sendScript(); }, 250); clearTimeout(saveTimer); @@ -842,9 +942,9 @@ export function mountController(root, code) { let sendSetT = 0, sendSetPending = false; function commitSettings(layout) { store.saveSettings(settings); - if (layout) relayout(stage, engine, () => stage.setSettings(settings)); - else stage.setSettings(settings); - renderTicks(); + if (layout) relayout(stage, engine, () => stage.setSettings(displaySettings(settings, profileFor(room.peersOf('prompter').find(p => p.id === primary))))); + else stage.setSettings(displaySettings(settings, profileFor(room.peersOf('prompter').find(p => p.id === primary)))); + renderTicks(); fitPreview(); const now = performance.now(); if (now - sendSetT > 50) { sendSetT = now; room.send('settings', { s: settings }); } else if (!sendSetPending) { @@ -1013,11 +1113,12 @@ export function mountController(root, code) { function renderReady() { const ps = room.peersOf('prompter'); const info = ps.find(p => p.id === primary) || ps[0]; - const text = info ? readyText(readiness.get(info.id) || info.hello?.ready) : t('Подключите телефон по QR. Настройки и текст можно подготовить заранее.'); + const text = primaryStale ? t('Нет свежих данных — последний кадр') : info ? readyText(readiness.get(info.id) || info.hello?.ready) : t('Подключите телефон по QR. Настройки и текст можно подготовить заранее.'); if ($('.device-ready').textContent !== text) $('.device-ready').textContent = text; } function openWindow() { - const w = window.open(roomUrl('p', code), 'efir-prompter-' + code, 'popup,width=1280,height=760'); + const url = new URL(roomUrl('p', code)); url.searchParams.set('screen', uid()); + const w = window.open(url.href, '_blank', 'popup,width=1280,height=760'); if (!w) toast(t('Браузер заблокировал окно — разрешите всплывающие окна'), 'bad'); } function openKeys() { remotes.open(); } @@ -1083,6 +1184,7 @@ export function mountController(root, code) { renderProfiles(); renderReady(); syncEditor(); fitPreview(); + const freshnessTimer = setInterval(refreshPrimaryPreview, 1000); if (document.fonts) document.fonts.ready.then(() => { stage.invalidate(); renderTicks(); }); const releaseLanguage = onLanguageChange(() => { renderSettings(); @@ -1095,8 +1197,9 @@ export function mountController(root, code) { destroy() { releaseLanguage(); releaseTranslation(); window.removeEventListener('efirnetwork', onNetwork); - frames.destroy(); stage.destroy(); + views.destroy(); frames.destroy(); stage.destroy(); previewObserver.disconnect(); + clearInterval(freshnessTimer); clearTimeout(edTimer); clearTimeout(saveTimer); document.removeEventListener('visibilitychange', saveBeforeHide); platform.events.removeEventListener('visible', saveBeforeHide); diff --git a/web/js/display-profile.js b/web/js/display-profile.js new file mode 100644 index 0000000..f6037f3 --- /dev/null +++ b/web/js/display-profile.js @@ -0,0 +1,28 @@ +// Per-display overrides; all omitted values inherit the session settings. +export const DISPLAY_FIELDS = { + fontSize: ['Размер', 16, 240], sidePad: ['Боковые поля', 0, 35], + leftPad: ['Левое поле', 0, 35], rightPad: ['Правое поле', 0, 35], + topPad: ['Сверху до текста', 0, 100], bottomPad: ['Снизу после текста', 0, 150], + markerPos: ['Положение линии чтения', 5, 95], + mirrorH: ['Зеркало ↔'], mirrorV: ['Зеркало ↕'], +}; +export function cleanDisplayProfile(value) { + const result = { name: '', overrides: {} }; + if (!value || typeof value !== 'object') return result; + if (typeof value.name === 'string') result.name = value.name.trim().slice(0, 60); + for (const [key, [, min, max]] of Object.entries(DISPLAY_FIELDS)) { + const v = value.overrides?.[key]; + if (min == null) { if (typeof v === 'boolean') result.overrides[key] = v; } + else if (Number.isFinite(v)) result.overrides[key] = Math.max(min, Math.min(max, v)); + } + return result; +} +export function displaySettings(settings, profile) { + return { ...settings, ...cleanDisplayProfile(profile).overrides }; +} +// Geometry, not color/mirror, decides whether a pixel position can be reused. +export function layoutSignature(settings) { + return JSON.stringify(['fontSize', 'fontFamily', 'fontWeight', 'lineHeight', 'letterSpacing', + 'uppercase', 'sidePad', 'leftPad', 'rightPad', 'topPad', 'bottomPad', 'markerPos', 'align'] + .map(key => settings[key] ?? null)); +} diff --git a/web/js/display-views.js b/web/js/display-views.js new file mode 100644 index 0000000..f9ea8ea --- /dev/null +++ b/web/js/display-views.js @@ -0,0 +1,173 @@ +import { t } from './i18n.js'; +import { Stage, Engine } from './stage.js'; +import { FrameLoop } from './frames.js'; +import { restoreSession } from './session.js'; +import { cleanDisplayProfile, displaySettings, DISPLAY_FIELDS } from './display-profile.js'; +import { modal } from './ui.js'; + +const esc = value => String(value).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); +export const screenId = peer => peer?.hello?.displayId || peer?.id; +export function profileDialog(profile, settings, change) { + let current = cleanDisplayProfile(profile); + const dialog = modal(`

${t('Настройки этого экрана')}

+ +

${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 `
+ ${min == null ? `` + : ``}
`; + }).join(''); + } + render(); + dialog.el.querySelector('.display-name').oninput = e => { current = { ...current, name: e.target.value }; change(cleanDisplayProfile(current)); }; + fields.onchange = e => { + const row = e.target.closest('[data-display-field]'); if (!row) return; + const key = row.dataset.displayField, input = row.querySelector('.display-value'); + if (e.target.matches('.display-own')) { + if (e.target.checked) current.overrides[key] = input.type === 'checkbox' ? input.checked : Number(input.value); + else delete current.overrides[key]; + change(cleanDisplayProfile(current)); render(); + } else if (input.type === 'checkbox' || input.validity.valid) { + current.overrides[key] = input.type === 'checkbox' ? input.checked : Number(input.value); + current = cleanDisplayProfile(current); change(current); + } + }; + dialog.el.querySelector('[data-display-reset]').onclick = () => { current.overrides = {}; change(cleanDisplayProfile(current)); render(); }; + return dialog; +} + +// Geometry comparison only. Independent layouts need their own exact previews. +export function formatRect(target, others, mirror) { + let left = 0, top = 0, right = target.w, bottom = target.h; + const marker = screen => screen.h * (mirror && screen.s.mirrorV ? 100 - screen.s.markerPos : screen.s.markerPos) / 100; + for (const other of others) { + if (!other.w || !other.h) continue; + const scale = Math.min(target.w / other.w, target.h / other.h); + const w = other.w * scale, h = other.h * scale; + const x = (target.w - w) / 2, y = marker(target) - marker(other) * scale; + left = Math.max(left, x); top = Math.max(top, y); + right = Math.min(right, x + w); bottom = Math.min(bottom, y + h); + } + return { left, top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) }; +} +export function showFormatRegion(region, stage, others, scale, enabled, mirror) { + region.hidden = !enabled || !others.length; + if (region.hidden) return; + const rect = formatRect(stage, others, mirror); + for (const key of ['left', 'top', 'width', 'height']) region.style[key] = rect[key] * scale + 'px'; +} + +export class DisplayViews { + constructor(host, { configure, profile, ready }) { + this.host = host; this.configure = configure; this.profile = profile; this.ready = ready; + this.active = true; this.records = new Map(); this.primary = null; this.script = ''; this.settings = null; + this.staleTimer = setInterval(() => this.checkStale(), 1000); + } + sync(peers, primary, settings, script, mirror) { + this.primary = primary; this.settings = settings; this.script = script; this.mirror = mirror; + for (const record of this.records.values()) record.peer = null; + for (const peer of peers) { + const id = screenId(peer); + if (peer.id === primary) { + const old = this.records.get(id); + if (old) { old.frames.destroy(); old.stage.destroy(); old.el.remove(); this.records.delete(id); } + continue; + } + let record = this.records.get(id); + if (!record) { + const el = document.createElement('div'); el.className = 'monitor screen-card'; el.dataset.screenId = id; + el.innerHTML = `
+
`; + this.host.appendChild(el); + const stage = new Stage(el.querySelector('.preview-scale')), engine = new Engine(stage); + record = { el, stage, engine, peer, state: null, text: null, settingsKey: null, seen: 0 }; + record.frames = new FrameLoop(stage, engine, () => { + stage.render(engine.snapshot()); stage.setRead(engine.mode === 'voice' && stage.s.dimRead ? engine.voiceIdx : -1); + }); + record.frames.setActive(false); + el.querySelector('.screen-config').onclick = () => { if (record.peer) this.configure(record.peer); }; + this.records.set(id, record); + } + if (record.connectionId !== peer.id) { + record.connectionId = peer.id; record.state = null; record.seen = 0; record.stale = false; + record.el.classList.remove('stale'); record.frames.setActive(false); + } + record.peer = peer; + record.el.hidden = peer.id === primary; + const s = displaySettings(settings, this.profile(peer)); + const key = JSON.stringify(s); + if (record.settingsKey !== key) { record.stage.setSettings(s); record.settingsKey = key; } + if (record.text !== script) { record.stage.setScript(script); record.text = script; } + const info = { ...peer.hello, ...record.state }; + record.stage.setViewport(info.vw || 1280, info.vh || 720); + record.stage.setShowMirror(mirror); + this.label(record); + } + for (const record of this.records.values()) { + if (record.peer) continue; + record.frames.setActive(false); + record.el.hidden = false; this.label(record); + } + // Retain a small number of disconnected displays for a truthful last preview. + for (const [id, record] of this.records) { + if (this.records.size <= 16) break; + if (!record.peer) { record.frames.destroy(); record.stage.destroy(); record.el.remove(); this.records.delete(id); } + } + } + label(record) { + const p = record.peer, profile = p ? this.profile(p) : null; + if (p) record.name = profile.name || p.hello?.name || t('Суфлёр'); + record.el.classList.toggle('offline', !p); + record.el.querySelector('.screen-label').textContent = `${record.name || t('Суфлёр')} · ${record.stage.w}×${record.stage.h}`; + record.el.querySelector('.screen-ready').textContent = !p ? t('Отключён — последний кадр') : !record.state ? t('Синхронизация…') : record.stale ? t('Нет свежих данных — последний кадр') : this.ready(p); + record.el.querySelector('.screen-config').disabled = !p; + record.el.querySelector('.screen-config').textContent = t('Настройки этого экрана'); + } + state(peer, state) { + const record = this.records.get(screenId(peer)); if (!record) return; + record.state = state; record.seen = performance.now(); record.stale = false; record.el.classList.remove('stale'); + record.stage.setViewport(state.vw, state.vh); this.label(record); + if (!this.active) return; + record.frames.setActive(true); + restoreSession(record.stage, record.engine, state); + record.engine.voiceLive = state.voiceState === 'hearing'; + record.frames.request(); + } + fit(width, height, others, compare) { + for (const record of this.records.values()) { + const { el, stage } = record; + const scale = Math.min(Math.max(1, width - 12) / stage.w, Math.max(1, height - 70) / stage.h); + const frame = el.querySelector('.screen-frame'); frame.style.width = stage.w * scale + 'px'; frame.style.height = stage.h * scale + 'px'; + el.querySelector('.screen-canvas').style.height = Math.max(1, height - 70) + 'px'; + el.querySelector('.preview-scale').style.transform = `scale(${scale})`; + showFormatRegion(el.querySelector('.format-region'), stage, others.filter(s => s !== stage), scale, compare && !!record.peer, this.mirror); + } + } + setActive(active) { + this.active = active; + for (const record of this.records.values()) { + const enabled = active && !!record.peer && !!record.state && !record.stale; + if (enabled && !record.frames.active) { + record.stage.invalidate(); + if (record.state && record.peer) restoreSession(record.stage, record.engine, record.state); + } + record.frames.setActive(enabled); + } + } + checkStale() { + if (!this.active) return; + for (const record of this.records.values()) { + if (!record.peer || !record.state) continue; + if (performance.now() - record.seen > 3500 && !record.stale) { + record.stale = true; record.el.classList.add('stale'); record.frames.setActive(false); this.label(record); + } + } + } + stages() { return [...this.records.values()].filter(r => r.peer).map(r => r.stage); } + destroy() { clearInterval(this.staleTimer); for (const r of this.records.values()) { r.frames.destroy(); r.stage.destroy(); } this.records.clear(); } +} diff --git a/web/js/frames.js b/web/js/frames.js index ffb9c4e..17d1765 100644 --- a/web/js/frames.js +++ b/web/js/frames.js @@ -3,7 +3,7 @@ import { platform } from './platform.js'; export class FrameLoop { constructor(stage, engine, draw) { this.stage = stage; this.engine = engine; this.draw = draw; - this.raf = 0; this.last = 0; this.dead = false; + this.raf = 0; this.last = 0; this.dead = false; this.active = true; this.onVisible = () => { cancelAnimationFrame(this.raf); this.raf = 0; this.last = 0; if (platform.visible) this.request(); @@ -15,8 +15,15 @@ export class FrameLoop { this.request(); } + setActive(active) { + if (this.active === active) return; + this.active = active; + cancelAnimationFrame(this.raf); this.raf = 0; this.last = 0; + if (active) this.request(); + } + request() { - if (this.dead || this.raf || !platform.visible) return; + if (this.dead || !this.active || this.raf || !platform.visible) return; this.raf = requestAnimationFrame(t => { this.raf = 0; const dt = this.last ? Math.min(.1, (t - this.last) / 1000) : 0; diff --git a/web/js/prompter.js b/web/js/prompter.js index c9c2604..c737582 100644 --- a/web/js/prompter.js +++ b/web/js/prompter.js @@ -1,21 +1,23 @@ import { t, translateUI, onLanguageChange, languagePicker } from './i18n.js'; import { localMode, hostName } from './environment.js'; import { platform } from './platform.js'; -import { captureSession, restoreSession } from './session.js'; +import { captureSession, restoreSession, sessionPosition } from './session.js'; // Reader: owns scroll position. The controller sends commands and receives // state at up to 30 updates per second. -import { store, DEFAULTS } from './store.js'; +import { cleanDisplayProfile, displaySettings } from './display-profile.js'; +import { profileDialog } from './display-views.js'; +import { store, DEFAULTS, uid } from './store.js'; import { Stage, Engine } from './stage.js'; import { mountRemotes } from './remotes.js'; import { FrameLoop } from './frames.js'; import { Room } from './net.js'; import { VoiceTracker, voiceSupported } from './voice.js'; import { mountLanHelp } from './lan.js'; -import { icon, qrSvg, toggleFullscreen, keepAwake, roomUrl, toast } from './ui.js'; +import { icon, qrSvg, toggleFullscreen, keepAwake, roomUrl, toast, closeModals } from './ui.js'; -const LAYOUT_KEYS = ['fontSize', 'fontFamily', 'fontWeight', 'lineHeight', 'letterSpacing', 'uppercase', 'sidePad', 'topPad', 'bottomPad', 'markerPos', 'align']; +const LAYOUT_KEYS = ['leftPad', 'rightPad', 'fontSize', 'fontFamily', 'fontWeight', 'lineHeight', 'letterSpacing', 'uppercase', 'sidePad', 'topPad', 'bottomPad', 'markerPos', 'align']; export function deviceName() { if (platform.native) return platform.name; @@ -46,8 +48,15 @@ export function relayout(stage, engine, apply) { export function mountPrompter(root, code) { document.body.className = 'is-prompter'; - const cache = store.prompterCache() || {}; - let settings = { ...DEFAULTS, ...(cache.settings || {}) }; + const url = new URL(location.href); + let displayId = url.searchParams.get('screen'); + if (!/^[a-zA-Z0-9-]{1,80}$/.test(displayId || '')) { displayId = uid(); url.searchParams.set('screen', displayId); history.replaceState(null, '', url); } + const displayBorn = Date.now(); + const cache = store.prompterCache(displayId) || {}; + let profile = cleanDisplayProfile(store.displayProfiles()[displayId]); + let baseSettings = { ...DEFAULTS, ...(cache.settings || {}) }; + let settings = displaySettings(baseSettings, profile); + let leader = null, commandTag = ''; let script = cache.script || null; let voiceState = 'off'; let syncTag = '', pendingSession = null; @@ -79,6 +88,7 @@ export function mountPrompter(root, code) {
${code}
${languagePicker()} + @@ -117,7 +127,7 @@ export function mountPrompter(root, code) { // Networking const room = new Room(code, 'prompter', () => ({ - vw: stage.w, vh: stage.h, name: deviceName(), + vw: stage.w, vh: stage.h, name: profile.name || deviceName(), displayId, displayBorn, profile, sid: script ? script.id + ':' + script.updated : null, session: captureSession(stage, engine, script ? script.id + ':' + script.updated : null), ready: currentReady(), })); @@ -141,6 +151,13 @@ export function mountPrompter(root, code) { room.addEventListener('leave', setStatus); room.addEventListener('join', (e) => { setStatus(); + if (e.detail.role === 'prompter') { + const peer = room.members.get(e.detail.id); + if (peer?.hello?.displayId === displayId && (peer.hello.displayBorn < displayBorn || (peer.hello.displayBorn === displayBorn && e.detail.id < room.id))) { + displayId = uid(); url.searchParams.set('screen', displayId); history.replaceState(null, '', url); + store.saveDisplayProfile(displayId, profile); persist(); room.send('hello', room.getHello()); + } + } if (e.detail.role === 'controller' && !e.detail.again) toast(t('Пульт подключён')); lastSent = 0; frames?.request(); @@ -161,7 +178,22 @@ export function mountPrompter(root, code) { if (p.cnt === m.n) { parts.delete(m.id); setScript({ id: m.id, title: m.title, updated: m.upd, text: p.got.join('') }); } break; } - case 'settings': applySettings(m.s); break; + case 'settings': + if (m.profile) { profile = cleanDisplayProfile(m.profile); store.saveDisplayProfile(displayId, profile); } + applySettings(m.s); break; + case 'display': setProfile(m.profile); break; + case 'follow': { + const previous = leader; leader = m.leader; + if (leader && leader !== room.id) { clearTimeout(micTimer); el.classList.remove('need-mic'); } + if (engine.mode === 'voice' && leader !== previous) { tracker.stop(); if (settings.voiceDevice === 'prompter' && (!leader || leader === room.id)) startVoice(); } + break; + } + case 'followState': + if (m.leader !== leader || leader === room.id || m.state?.sid !== script?.id + ':' + script?.updated) break; + restoreSession(stage, engine, m.state); + voiceState = m.state.voiceState || 'off'; engine.voiceLive = voiceState === 'hearing'; + speedV.textContent = engine.speed.toFixed(1); lastSent = 0; frames?.request(); + break; case 'session': pendingSession = m; applySession(); @@ -190,13 +222,14 @@ export function mountPrompter(root, code) { restoreSession(stage, engine, state); syncTag = tag; tracker.setPosition(engine.voiceIdx); voiceState = state.voiceState || 'off'; - if (engine.mode === 'voice' && settings.voiceDevice === 'prompter') startVoice(); + if (engine.mode === 'voice' && settings.voiceDevice === 'prompter' && (!leader || leader === room.id)) startVoice(); speedV.textContent = engine.speed.toFixed(1); refreshTouchLock(); persist(); sendState(true); frames?.request(); } function applySettings(s) { - const next = { ...DEFAULTS, ...s }; + baseSettings = { ...DEFAULTS, ...s }; + const next = displaySettings(baseSettings, profile); const layout = LAYOUT_KEYS.some(k => next[k] !== settings[k]); const prevLang = settings.voiceLang, prevDev = settings.voiceDevice; settings = next; @@ -204,13 +237,30 @@ export function mountPrompter(root, code) { else stage.setSettings(settings); if (engine.mode === 'voice' && (prevLang !== settings.voiceLang || prevDev !== settings.voiceDevice)) { tracker.stop(); - if (settings.voiceDevice === 'prompter') startVoice(); + if (settings.voiceDevice === 'prompter' && (!leader || leader === room.id)) startVoice(); } persist(true); refreshTouchLock(); refreshMirror(); } + function setProfile(value, broadcast = false) { + profile = cleanDisplayProfile(value); store.saveDisplayProfile(displayId, profile); + applySettings(baseSettings); lastSent = 0; frames?.request(); + if (broadcast) room.send('displayChange', { profile }); + } + function requestCommand(c, m = {}) { + if (room.peersOf('controller').length) { + if (c === 'nudge') room.send('control', { c, lines: m.v / (settings.fontSize * settings.lineHeight) }); + else room.send('control', { ...m, c }); + } else command(c, m); + } + function requestSeek(pos, smooth = false) { + if (!room.peersOf('controller').length) { seekTo(pos, smooth); return; } + const state = captureSession(stage, { pos: stage.clamp(pos), snapshot: () => ({ ...engine.snapshot(), pos: stage.clamp(pos) }) }, script ? script.id + ':' + script.updated : null); + room.send('control', { c: 'seek', state, smooth }); + } + // Controller and keyboard commands function seekTo(p, smooth = true) { engine.seek(p, smooth); @@ -222,10 +272,11 @@ export function mountPrompter(root, code) { frames?.request(); } function section(dir) { - seekTo(stage.sectionStep(engine.pos, dir)); + requestCommand('section', { dir }); } function command(c, m = {}) { + if (m.tag) commandTag = m.tag; switch (c) { case 'toggle': engine.toggle(settings.countdown); break; case 'play': engine.play(settings.countdown); break; @@ -236,10 +287,10 @@ export function mountPrompter(root, code) { case 'nudge': engine.nudge = m.v || 0; break; case 'seek': { const same = m.vw === stage.w && m.vh === stage.h; - seekTo(same || m.word == null ? m.pos : stage.posForWord(m.word), !!m.smooth); + seekTo(m.state ? sessionPosition(stage, m.state) : same || m.word == null ? m.pos : stage.posForWord(m.word), !!m.smooth); break; } - case 'section': m.k != null ? seekTo(stage.posForSection(m.k)) : section(m.dir); break; + case 'section': seekTo(m.k != null ? stage.posForSection(m.k) : stage.sectionStep(engine.pos, m.dir)); break; case 'voice': m.on ? voiceOn(m.idx) : voiceOff(); break; case 'voiceState': if (localMode && settings.voiceDevice === 'controller') voiceState = m.state; @@ -276,9 +327,10 @@ export function mountPrompter(root, code) { engine.mode = 'voice'; engine.voiceIdx = idx != null ? idx : stage.wordAt(engine.pos); tracker.setPosition(engine.voiceIdx); - if (settings.voiceDevice === 'prompter') startVoice(); + if (settings.voiceDevice === 'prompter' && (!leader || leader === room.id)) startVoice(); } function startVoice() { + if (leader && leader !== room.id) return; if (!voiceSupported) { voiceState = 'unsupported'; toast(localMode ? t('По HTTP микрофон телефона недоступен. Используйте микрофон пульта ({name}).', { name: hostName }) : t('Этот браузер не умеет распознавать речь'), 'bad'); return; } tracker.start(settings.voiceLang); // Some browsers require a user gesture to start the microphone. @@ -330,7 +382,6 @@ export function mountPrompter(root, code) { function togglePlayback() { const c = engine.playing || engine.mode === 'voice' ? 'pause' : 'play'; command(c); - // Absolute commands also work when several controllers receive the request. if (room.peersOf('controller').length) room.send('control', { c }); } function refreshMirror() { @@ -342,19 +393,19 @@ export function mountPrompter(root, code) { if (!b) return; showUI(); const a = b.dataset.a; - if (a === 'keys') remotes.open(); + if (a === 'display') profileDialog(profile, baseSettings, value => setProfile(value, true)); + else if (a === 'keys') remotes.open(); else if (a === 'fs') toggleFullscreen(); else if (a === 'mirror') { - applySettings({ ...settings, mirrorH: !settings.mirrorH }); - room.send('mirror', { value: settings.mirrorH }); + setProfile({ ...profile, overrides: { ...profile.overrides, mirrorH: !settings.mirrorH } }, true); } else if (a === 'toggle') togglePlayback(); else if (a === 'qr') el.classList.toggle('show-qr'); else if (a === 'prev') section(-1); else if (a === 'next') section(1); - else if (a === 'slower') command('speedBy', { v: -0.2 }); - else if (a === 'faster') command('speedBy', { v: 0.2 }); - else command(a); + else if (a === 'slower') requestCommand('speed', { v: clampSpeed(engine.speed - .2) }); + else if (a === 'faster') requestCommand('speed', { v: clampSpeed(engine.speed + .2) }); + else requestCommand(a); }); el.addEventListener('mousemove', (e) => { if (e.movementX || e.movementY) showUI(); }); @@ -372,7 +423,7 @@ export function mountPrompter(root, code) { const dy = (e.clientY - drag.y) * (settings.mirrorV ? -1 : 1); if (!drag.moved && Math.abs(dy) > 8) { clearTimeout(longPress); drag.moved = true; stageHost.setPointerCapture(e.pointerId); } if (isTouchLocked()) return; - if (drag.moved) seekTo(drag.pos - dy, false); + if (drag.moved) requestSeek(drag.pos - dy, false); }); stageHost.addEventListener('pointerup', (e) => { if (!drag || e.pointerId !== drag.id) return; @@ -384,7 +435,7 @@ export function mountPrompter(root, code) { stageHost.addEventListener('wheel', (e) => { e.preventDefault(); if (isTouchLocked()) return; - seekTo(engine.pos + e.deltaY * (settings.mirrorV ? -1 : 1), false); + requestSeek(engine.pos + e.deltaY * (settings.mirrorV ? -1 : 1), false); }, { passive: false }); el.querySelector('.p-qr-pop').onclick = () => el.classList.remove('show-qr'); $('.p-lock').onclick = () => { touchUnlocked = !touchUnlocked; refreshTouchLock(); if (!lockState) showUI(); }; @@ -413,24 +464,30 @@ export function mountPrompter(root, code) { } const remotes = mountRemotes({ - toggle: togglePlayback, start: () => command('start'), - faster: () => command('speedBy', { v: .2 }), slower: () => command('speedBy', { v: -.2 }), + toggle: togglePlayback, start: () => requestCommand('start'), + faster: () => requestCommand('speed', { v: clampSpeed(engine.speed + .2) }), slower: () => requestCommand('speed', { v: clampSpeed(engine.speed - .2) }), next: () => section(1), prev: () => section(-1), - forward: held => command('nudge', { v: held ? settings.fontSize * settings.lineHeight * 3.5 : 0 }), - back: held => command('nudge', { v: held ? -settings.fontSize * settings.lineHeight * 3.5 : 0 }), + forward: held => requestCommand('nudge', { v: held ? settings.fontSize * settings.lineHeight * 3.5 : 0 }), + back: held => requestCommand('nudge', { v: held ? -settings.fontSize * settings.lineHeight * 3.5 : 0 }), fullscreen: toggleFullscreen, - mirror: () => { applySettings({ ...settings, mirrorH: !settings.mirrorH }); room.send('mirror', { value: settings.mirrorH }); }, + mirror: () => setProfile({ ...profile, overrides: { ...profile.overrides, mirrorH: !settings.mirrorH } }, true), }); // Drawing and state exchange - let lastSent = 0, prevSig = ''; + let lastSent = 0, prevSig = '', stateFlush = 0; function sendState(force = false) { if (!room.peersOf('controller').length || document.hidden) return; const now = performance.now(); - const sig = `${engine.pos.toFixed(1)}|${engine.playing}|${engine.speed}|${Math.ceil(engine.cd)}|${engine.mode}|${engine.voiceIdx}|${voiceState}|${stage.w}x${stage.h}|${stage.max}`; - if (!force && (sig === prevSig || now - lastSent < 33)) return; + const sig = `${engine.pos.toFixed(1)}|${engine.playing}|${engine.speed}|${Math.ceil(engine.cd)}|${engine.mode}|${engine.voiceIdx}|${voiceState}|${stage.w}x${stage.h}|${stage.max}|${stage.layout}|${commandTag}|${JSON.stringify(profile)}|${script?.id}:${script?.updated}|${syncTag}`; + if (!force && sig === prevSig) return; + if (!force && now - lastSent < 33) { + if (!stateFlush) stateFlush = setTimeout(() => { stateFlush = 0; sendState(); }, 33 - (now - lastSent)); + return; + } prevSig = sig; lastSent = now; room.send('state', { + ...captureSession(stage, engine, script ? script.id + ':' + script.updated : null), + command: commandTag, displayId, profile, pos: engine.pos, playing: engine.playing, speed: engine.speed, cd: engine.cd, mode: engine.mode, voiceIdx: engine.voiceIdx, voiceState, max: stage.max, vw: stage.w, vh: stage.h, sid: script ? script.id + ':' + script.updated : null, @@ -457,7 +514,7 @@ export function mountPrompter(root, code) { platform.events.addEventListener('visible', onVisibility); function persist() { - store.setPrompterCache({ script, settings, speed: engine.speed, pos: engine.pos }); + store.setPrompterCache({ script, settings: baseSettings, speed: engine.speed, pos: engine.pos }, displayId); } speedV.textContent = engine.speed.toFixed(1); @@ -469,7 +526,8 @@ export function mountPrompter(root, code) { return { destroy() { releaseTranslation(); releaseLanguage(); - persist(); + persist(); closeModals(); + clearTimeout(stateFlush); frames.destroy(); stage.destroy(); clearInterval(stateTimer); clearInterval(saveTimer); clearTimeout(micTimer); diff --git a/web/js/session.js b/web/js/session.js index b0492b0..edceb46 100644 --- a/web/js/session.js +++ b/web/js/session.js @@ -4,17 +4,40 @@ export function captureSession(stage, engine, sid) { stage.measure(); const word = stage.wordAt(engine.pos); const top = stage.tops?.[word] ?? stage.markerY; - return { ...engine.snapshot(), sid, vw: stage.w, vh: stage.h, word, + const lines = stage.lines || []; + const y = engine.pos + stage.markerY; + let lo = 0, hi = Math.max(0, lines.length - 1); + while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); if (stage.tops[lines[mid]] <= y) lo = mid; else hi = mid - 1; } + const a = lines[lo] ?? 0, b = lines[lo + 1] ?? a; + const ay = stage.tops?.[a] ?? stage.markerY, by = stage.tops?.[b] ?? ay; + const f = (y - ay) / (by > ay ? by - ay : stage.s.fontSize * stage.s.lineHeight); + return { ...engine.snapshot(), sid, atStart: engine.pos < .01, atEnd: stage.max > 0 && engine.pos >= stage.max, layout: stage.layout, anchor: { a, b, f }, vw: stage.w, vh: stage.h, word, offset: (engine.pos + stage.markerY - top) / (stage.s.fontSize * stage.s.lineHeight) }; } +// Interpolate between source line starts, avoiding jumps when a wider source +// line wraps into several lines on a narrower follower. +export function sessionPosition(stage, state) { + stage.measure(); + if (state.atStart) return 0; + if (state.atEnd) return stage.max; + if (state.vw === stage.w && state.vh === stage.h && (!state.layout || state.layout === stage.layout)) return stage.clamp(Number.isFinite(state.pos) ? state.pos : 0); + const anchor = state.anchor; + if (anchor && Number.isInteger(anchor.a) && Number.isInteger(anchor.b) && Number.isFinite(anchor.f)) { + const last = stage.words.length - 1; + const a = Math.max(0, Math.min(last, anchor.a)), b = Math.max(a, Math.min(last, anchor.b)); + const ay = stage.tops?.[a] ?? stage.markerY, by = stage.tops?.[b] ?? ay; + return stage.clamp(ay + anchor.f * (b !== a ? by - ay : stage.s.fontSize * stage.s.lineHeight) - stage.markerY); + } + const word = Math.max(0, Math.min(stage.words.length - 1, Number.isFinite(state.word) ? state.word : 0)); + return stage.clamp((stage.tops?.[word] ?? stage.markerY) + (Number.isFinite(state.offset) ? state.offset : 0) * stage.s.fontSize * stage.s.lineHeight - stage.markerY); +} + export function restoreSession(stage, engine, state) { stage.measure(); const number = (v, fallback) => Number.isFinite(v) ? v : fallback; const word = Math.max(0, Math.min(stage.words.length - 1, number(state.word, 0))); - const pos = state.vw === stage.w && state.vh === stage.h ? number(state.pos, 0) - : (stage.tops?.[word] ?? stage.markerY) + number(state.offset, 0) * stage.s.fontSize * stage.s.lineHeight - stage.markerY; - engine.pos = stage.clamp(pos); + engine.pos = sessionPosition(stage, state); engine.target = null; engine.nudge = 0; engine.speed = Math.max(.1, Math.min(20, number(state.speed, engine.speed))); engine.mode = state.mode === 'voice' ? 'voice' : 'scroll'; diff --git a/web/js/stage.js b/web/js/stage.js index 73286c5..7cd2a84 100644 --- a/web/js/stage.js +++ b/web/js/stage.js @@ -1,5 +1,6 @@ import { t, translateUI, onLanguageChange } from './i18n.js'; import { normWord, wordChunks } from './words.js'; +import { layoutSignature } from './display-profile.js'; import { localMode } from './environment.js'; export { normWord } from './words.js'; // Stage renders both the reader and controller preview. The preview uses @@ -170,6 +171,8 @@ export class Stage { st.setProperty('--nc', s.noteColor); st.setProperty('--mc', s.markerColor); st.setProperty('--side', (s.sidePad * this.w / 100) + 'px'); + st.setProperty('--left', ((s.leftPad ?? s.sidePad) * this.w / 100) + 'px'); + st.setProperty('--right', ((s.rightPad ?? s.sidePad) * this.w / 100) + 'px'); st.setProperty('--top', (s.topPad * this.h / 100) + 'px'); st.setProperty('--bottom', (s.bottomPad * this.h / 100) + 'px'); st.setProperty('--my', (s.markerPos * this.h / 100) + 'px'); @@ -218,9 +221,15 @@ export class Stage { const e = this.wordEls[i]; this.tops[i] = e.offsetTop + e.offsetHeight / 2; } + this.lines = []; + for (let i = 0; i < n; i++) { + if (!i || Math.abs(this.tops[i] - this.tops[i - 1]) > 1) this.lines.push(i); + } this.secTops = [...this.text.querySelectorAll('.sec')].map(e => e.offsetTop); } + get layout() { return layoutSignature(this.s || {}); } + get markerY() { return (this.s ? this.s.markerPos : 40) * this.h / 100; } pxPerSec(speed) { diff --git a/web/js/store.js b/web/js/store.js index 06703ac..d77aebf 100644 --- a/web/js/store.js +++ b/web/js/store.js @@ -1,3 +1,4 @@ +import { cleanDisplayProfile } from './display-profile.js'; import { t } from './i18n.js'; import { wordChunks } from './words.js'; import { localMode } from './environment.js'; @@ -66,6 +67,7 @@ const K = { prompterSettings: 'efir.prompter.settings.v1', id: 'efir.client.v1', profiles: 'efir.profiles.v1', + displays: 'efir.displays.v1', }; let savedPrompter = null, savedPosition = null; @@ -143,24 +145,38 @@ export const store = { room() { return read(K.room, null); }, setRoom(r) { write(K.room, r); }, - prompterCache() { - const c = read(K.cached, null), p = read(K.position, null); + prompterCache(displayId) { + const c = read(K.cached, null), p = read(displayId ? K.position + '.' + displayId : K.position, read(K.position, null)); const sid = c?.script ? c.script.id + ':' + c.script.updated : null; if (!c) return null; const settings = read(K.prompterSettings, c.settings); return { ...c, settings, ...(p?.sid === sid ? { pos: p.pos, speed: p.speed } : {}) }; }, - setPrompterCache(c) { + setPrompterCache(c, displayId) { // Write scripts and settings only on changes; save position separately. if (!savedPrompter || c.script !== savedPrompter.script) write(K.cached, { script: c.script }); if (!savedPrompter || c.settings !== savedPrompter.settings) write(K.prompterSettings, c.settings); savedPrompter = { script: c.script, settings: c.settings }; - const p = { sid: c.script ? c.script.id + ':' + c.script.updated : null, pos: Math.round(c.pos * 100) / 100, speed: c.speed }; - if (!savedPosition || p.sid !== savedPosition.sid || p.pos !== savedPosition.pos || p.speed !== savedPosition.speed) { - write(K.position, p); savedPosition = p; + const positionKey = displayId ? K.position + '.' + displayId : K.position; + const p = { key: positionKey, sid: c.script ? c.script.id + ':' + c.script.updated : null, pos: Math.round(c.pos * 100) / 100, speed: c.speed }; + if (!savedPosition || p.key !== savedPosition.key || p.sid !== savedPosition.sid || p.pos !== savedPosition.pos || p.speed !== savedPosition.speed) { + write(positionKey, p); savedPosition = p; } }, + displayProfiles() { + const profiles = read(K.displays, {}), clean = {}; + for (const [id, profile] of Object.entries(profiles || {}).slice(-64)) { + if (/^[a-zA-Z0-9-]{1,80}$/.test(id)) clean[id] = cleanDisplayProfile(profile); + } + return clean; + }, + saveDisplayProfile(id, profile) { + const profiles = this.displayProfiles(); + delete profiles[id]; profiles[id] = cleanDisplayProfile(profile); + write(K.displays, Object.fromEntries(Object.entries(profiles).slice(-64))); + }, + clientId() { let id = sessionStorageGet(K.id); if (!id) { id = uid(); sessionStorageSet(K.id, id); } diff --git a/web/locales/messages.json b/web/locales/messages.json index 65cca2a..8a23895 100644 --- a/web/locales/messages.json +++ b/web/locales/messages.json @@ -3280,6 +3280,118 @@ "Gamepad:{count}。请按已连接遥控器的按钮以检测设备。", "Gamepad: {count}। पहचानने के लिए जुड़े रिमोट का बटन दबाएँ।", "Gamepad: {count}. اضغط زرًا على جهاز التحكم المتصل لاكتشافه." + ], + "Настройки этого экрана": [ + "Настройки этого экрана", + "Settings for this screen", + "Ajustes de esta pantalla", + "此屏幕设置", + "इस स्क्रीन की सेटिंग", + "إعدادات هذه الشاشة" + ], + "Название экрана": [ + "Название экрана", + "Screen name", + "Nombre de la pantalla", + "屏幕名称", + "स्क्रीन का नाम", + "اسم الشاشة" + ], + "Без отдельной настройки экран использует общие значения с пульта.": [ + "Без отдельной настройки экран использует общие значения с пульта.", + "Fields without an override inherit the controller’s shared settings.", + "Los campos sin ajuste propio heredan los ajustes comunes del controlador.", + "未单独设置的字段使用控制台的共享设置。", + "अलग सेटिंग के बिना फ़ील्ड नियंत्रक की साझा सेटिंग इस्तेमाल करते हैं।", + "تستخدم الحقول بلا تخصيص الإعدادات المشتركة من لوحة التحكم." + ], + "Использовать общие настройки": [ + "Использовать общие настройки", + "Use shared settings", + "Usar ajustes comunes", + "使用共享设置", + "साझा सेटिंग इस्तेमाल करें", + "استخدام الإعدادات المشتركة" + ], + "Ведущий суфлёр": [ + "Ведущий суфлёр", + "Leading teleprompter", + "Teleprónter principal", + "主提词器", + "मुख्य टेलीप्रॉम्प्टर", + "الملقّن الرئيسي" + ], + "Все экраны": [ + "Все экраны", + "All screens", + "Todas las pantallas", + "所有屏幕", + "सभी स्क्रीन", + "كل الشاشات" + ], + "Сравнить форматы": [ + "Сравнить форматы", + "Compare formats", + "Comparar formatos", + "比较格式", + "प्रारूपों की तुलना करें", + "مقارنة الأبعاد" + ], + "Затемнение сравнивает форматы по линии чтения. При разных полях и шрифтах сверяйте отдельные превью.": [ + "Затемнение сравнивает форматы по линии чтения. При разных полях и шрифтах сверяйте отдельные превью.", + "Shading compares formats at the reading line. With different margins or fonts, check each separate preview.", + "El sombreado compara formatos en la línea de lectura. Con márgenes o fuentes distintos, revise cada vista previa.", + "阴影按阅读线比较格式。边距或字体不同时,请查看各屏幕预览。", + "छायांकन पढ़ने की रेखा पर प्रारूपों की तुलना करता है। अलग मार्जिन या फ़ॉन्ट के लिए हर पूर्वावलोकन देखें।", + "يقارن التظليل الأبعاد عند خط القراءة. عند اختلاف الهوامش أو الخطوط راجع كل معاينة منفصلة." + ], + "Левое поле": [ + "Левое поле", + "Left margin", + "Margen izquierdo", + "左边距", + "बायाँ मार्जिन", + "الهامش الأيسر" + ], + "Правое поле": [ + "Правое поле", + "Right margin", + "Margen derecho", + "右边距", + "दायाँ मार्जिन", + "الهامش الأيمن" + ], + "Отключён — последний кадр": [ + "Отключён — последний кадр", + "Disconnected — last frame", + "Desconectado — último fotograma", + "已断开 — 最后一帧", + "डिस्कनेक्ट — अंतिम फ़्रेम", + "غير متصل — آخر إطار" + ], + "16:10 · монитор": [ + "16:10 · монитор", + "16:10 · monitor", + "16:10 · monitor", + "16:10 · 显示器", + "16:10 · मॉनिटर", + "16:10 · شاشة" + ], + "Синхронизация…": [ + "Синхронизация…", + "Synchronizing…", + "Sincronizando…", + "正在同步…", + "सिंक हो रहा है…", + "جارٍ المزامنة…" + ], + "Нет свежих данных — последний кадр": [ + "Нет свежих данных — последний кадр", + "No recent data — last frame", + "Sin datos recientes — último fotograma", + "暂无最新数据 — 最后一帧", + "हाल का डेटा नहीं — अंतिम फ़्रेम", + "لا توجد بيانات حديثة — آخر إطار" ] } }