JavaRush /Курсы /ChatGPT Apps /Fullscreen и PiP: мастера, сложный контент, видео + чат

Fullscreen и PiP: мастера, сложный контент, видео + чат

ChatGPT Apps
8 уровень , 2 лекция
Открыта

2. Зачем вообще нужен fullscreen, если есть inline?

В предыдущей лекции про inline мы уже договорились: если задача короткая и укладывается в 5–7 объектов или один экран, inline-карточка — идеальный вариант. Список из нескольких подарков, пара фильтров, одна-две кнопки — всё это отлично живёт прямо в потоке сообщений.

Но у любого приложения наступает момент, когда «ещё одна карточка» уже не спасает:

  • нужно собрать много параметров (профиль получателя, ограничения по доставке, способы оплаты);
  • нужен мастер из нескольких шагов;
  • есть большие таблицы, графики, карты, длинные описания.

Inline здесь начинает нервно дёргаться: ширина ограничена колонкой чата, высота — тоже, навигации нет, да и скролл у чата один. Именно для таких сценариев в Apps SDK есть fullscreen‑режим — «погружённый» интерфейс, в котором ваш виджет занимает большую часть экрана и может показывать сложный layout.

Второй герой сегодняшнего дня — PiP, маленькое плавающее окно, которое живёт поверх чата. Его типичные роли: статус фоновой задачи, мини‑плеер, таймер, индикатор прогресса. PiP идеален, когда что-то длительное идёт «на фоне», а пользователь продолжает разговаривать с GPT.

Важно помнить: и fullscreen, и PiP — не замена inline, а надстройка. Начинаем с inline, а в fullscreen переходим, когда inline становится тесно; в PiP уходим, когда всё интересное уже запущено и нужно просто «держать под глазом» статус.

3. Технический фундамент: displayMode и переключения режимов

С точки зрения Apps SDK у вашего виджета есть текущее состояние отображенияdisplayMode. На момент написания курса есть три основных режима: "inline", "fullscreen" и "pip" (picture-in-picture).

Хост (ChatGPT) сообщает вашему виджету текущий режим через глобальные данные в window.openai и специальные хуки из SDK. В типичном React-шаблоне есть что-то вроде:

// псевдоним из Apps SDK-шаблона
const mode = useDisplayMode(); // 'inline' | 'fullscreen' | 'pip'

if (mode === "fullscreen") {
  // рендерим наш мастер
} else {
  // рендерим компактный inline UI
}

SDK также даёт метод window.openai.requestDisplayMode({ mode }) и/или хук useRequestDisplayMode, чтобы попросить хост переключить режим. Этот метод возвращает промис с фактически установленным режимом, потому что платформа может отказать или скорректировать ваш запрос (например, PiP на мобильном почти всегда превращается в fullscreen).

Схематично жизненный цикл режимов можно представить так:

stateDiagram-v2
    [*] --> Inline
    Inline --> Fullscreen: requestDisplayMode('fullscreen')
    Fullscreen --> Inline: requestDisplayMode('inline') / кнопка "Назад"
    Fullscreen --> PiP: requestDisplayMode('pip')
    PiP --> Fullscreen: "Развернуть"
    PiP --> Inline: завершение задачи

Реальные названия и точный набор режимов могут меняться с версиями SDK, поэтому в проде всегда стоит перепроверять документацию, а не полагаться на «как было в курсе».

4. Первое переключение: делаем кнопку «Развернуть в полноэкранный режим»

Начнём с малого: возьмём наш уже существующий inline‑виджет GiftGenius — учебный App из прошлых модулей, который сейчас показывает 3–5 карточек подарков, — и добавим в него кнопку «Открыть подробный подбор» для перехода в fullscreen.

Предположим, что у нас в шаблоне есть два хука:

import { useDisplayMode, useRequestDisplayMode } from "@/sdk/display";

export const GiftGeniusWidget: React.FC = () => {
  const mode = useDisplayMode();
  const requestDisplayMode = useRequestDisplayMode();

  if (mode === "fullscreen") {
    return <GiftFullscreenWizard />;
  }

  return (
    <InlineGiftPreview
      onExpand={async () => {
        await requestDisplayMode({ mode: "fullscreen" });
      }}
    />
  );
};

Здесь InlineGiftPreview — это наш текущий inline‑UI, а GiftFullscreenWizard — новый компонент‑мастер, который мы сейчас спроектируем. В обработчике onExpand мы не просто вызываем requestDisplayMode, но и ждём промис — так мы сможем позже реагировать на отказ (например, показать сообщение, если по какой-то причине fullscreen недоступен).

Сам InlineGiftPreview достаточно прост:

type InlineGiftPreviewProps = {
  onExpand: () => void;
};

const InlineGiftPreview: React.FC<InlineGiftPreviewProps> = ({ onExpand }) => {
  return (
    <div>
      <h3>Подбор подарков</h3>
      {/* ...карточки подарков... */}
      <button onClick={onExpand}>Открыть подробный подбор</button>
    </div>
  );
};

Пока что всё очень похоже на «открыть модалку», но разница в том, что контролирует это не ваш React, а хост‑приложение ChatGPT, и оно может показывать заголовок, системные кнопки «Назад» и т.п.

5. Проектируем fullscreen‑мастер GiftGenius

Теперь спроектируем fullscreen‑мастер подбора подарка. С UX‑точки зрения разумно разбить процесс на несколько логических шагов. Например:

  1. Кто получатель подарка и какой повод.
  2. Бюджет и тип подарков (физические, впечатления, цифровые).
  3. Проверка и подтверждение выбора.

В коде это можно отразить простой машиной состояний по шагам:

type WizardStep = "recipient" | "preferences" | "review";

type WizardState = {
  step: WizardStep;
  recipient?: { ageRange: string; relation: string };
  preferences?: { budget: number; categories: string[] };
};

Создадим компонент GiftFullscreenWizard, который хранит это состояние в React и рендерит нужный экран.

const GiftFullscreenWizard: React.FC = () => {
  const [state, setState] = useState<WizardState>({ step: "recipient" });

  const goNext = (partial: Partial<WizardState>) => {
    setState((prev) => ({ ...prev, ...partial }));
  };

  if (state.step === "recipient") {
    return <RecipientStep state={state} onNext={goNext} />;
  }

  if (state.step === "preferences") {
    return <PreferencesStep state={state} onNext={goNext} />;
  }

  return <ReviewStep state={state} />;
};

Каждый шаг — это маленький компонент с формой. Например, первый шаг:

type StepProps = {
  state: WizardState;
  onNext: (partial: Partial<WizardState>) => void;
};

const RecipientStep: React.FC<StepProps> = ({ state, onNext }) => {
  const [relation, setRelation] = useState(state.recipient?.relation ?? "");
  const [ageRange, setAgeRange] = useState(state.recipient?.ageRange ?? "");

  return (
    <div>
      <h2>Кому выбираем подарок?</h2>
      <input
        placeholder="Кто это для вас?"
        value={relation}
        onChange={(e) => setRelation(e.target.value)}
      />
      <input
        placeholder="Возраст (например, 25–34)"
        value={ageRange}
        onChange={(e) => setAgeRange(e.target.value)}
      />
      <button
        onClick={() =>
          onNext({
            recipient: { relation, ageRange },
            step: "preferences",
          })
        }
      >
        Далее
      </button>
    </div>
  );
};

Во втором шаге мы собираем бюджет и категории, в третьем — вызываем callTool / MCP‑инструмент, который уже умеет подбирать подарки по этим параметрам, и показываем результаты.

Важно, что на fullscreen‑экране у нас есть место для:

  • прогресс-бара или stepper’а;
  • более развёрнутых полей и подсказок;
  • состояний ошибки («что-то пошло не так, попробуйте ещё раз»).

Рекомендация из UX‑гайдлайнов: каждый шаг должен оставаться максимально простым, без перегруза полями; лучше 3–4 ясных шага, чем один монстр-формуляр.

6. UX fullscreen‑мастера: прогресс, ошибки, возврат

Просто вывести форму на весь экран — это половина дела. Пользователю нужно:

  • понимать, на каком он шаге;
  • иметь возможность вернуться назад;
  • видеть, что происходит во время долгих операций.

Простейший stepper можно реализовать чисто визуально:

const Stepper: React.FC<{ step: WizardStep }> = ({ step }) => {
  const index = step === "recipient" ? 1 : step === "preferences" ? 2 : 3;
  return <p>Шаг {index} из 3</p>;
};

И просто вставить Stepper в каждый экран. Более продвинутый вариант — отрендерить горизонтальную «лестницу» шагов, но в рамках курса не будем устраивать школу верстальщика.

Важный момент — обработка ошибок. Допустим, на последнем шаге мы вызываем инструмент search_gifts:

const ReviewStep: React.FC<StepProps> = ({ state }) => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleConfirm = async () => {
    setLoading(true);
    setError(null);
    try {
      await callTool("search_gifts", {
        recipient: state.recipient,
        preferences: state.preferences,
      });
      // Результаты потом появятся в чате / виджете
    } catch (e) {
      setError("Не удалось подобрать подарки, попробуйте ещё раз.");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      {/* показать сводку параметров */}
      {error && <p style={{ color: "red" }}>{error}</p>}
      <button disabled={loading} onClick={handleConfirm}>
        {loading ? "Подбираем…" : "Подтвердить и подобрать"}
      </button>
    </div>
  );
};

С точки зрения доступности нужно следить, чтобы:

  • в fullscreen крупные кнопки «Далее», «Назад» и «Отмена» были легко кликабельны;
  • текст имел адекватный контраст;
  • по Tab можно было пройти все интерактивные элементы по порядку.

Если у вас есть возможность — стоит добавить aria-label для нестандартных контролов (например, кастомных переключателей категорий). Хотя курс не превращается в WCAG‑экзамен, базовое внимание к a11y поможет вам пройти ревью Store потом без лишней боли.

В итоге fullscreen‑мастер решает задачу сложных многошаговых сценариев: даёт место для форм, прогресса и ошибок. Но жизнь приложения на этом не заканчивается — многие задачи продолжаются «в фоне». Для этого у нас есть второй режим — PiP, о котором поговорим дальше.

7. Что такое PiP в мире ChatGPT и почему он «капризный»

Мы разобрались, как использовать fullscreen для сложных сценариев. Теперь давайте посмотрим на противоположный кейс — когда всё важное уже запущено и нужно лишь «держать под контролем» прогресс. Здесь в дело вступает PiP.

В веб‑мире «picture-in-picture» обычно ассоциируется с видео, которое висит в углу экрана поверх контента. В ChatGPT PiP — это маленькое плавающее окно виджета, которое остаётся на виду при скролле чата и может показывать статус, прогресс или компактный UI.

Несколько важных особенностей, которые нужно знать из документации и опыта early‑адоптеров:

  1. У PiP очень мало места. Это не площадка для форм и сложных layout’ов, а скорее для двух-трёх ключевых метрик и одной-двух кнопок.
  2. На десктопе PiP «прилипает» вверху и остаётся видимым при любом скролле; на мобильных же он часто автоматически превращается в fullscreen.
  3. Запрос requestDisplayMode с mode "pip" не гарантирует настоящего PiP. Платформа может вернуть другой режим (например, fullscreen) или вообще повести себя странно на старых версиях SDK, поэтому всегда проверяйте результат промиса и имейте fallback.

Из этого вытекает простой UX‑вывод: в PiP — только самое важное. Таймер, индикатор доставки, статус задачи, кнопка «Развернуть». Никаких 12 чекбоксов, таблиц на 10 колонок и «сделайте мне ещё кофе».

8. GiftGenius + PiP: долгий поиск и фоновый прогресс

Вернёмся к GiftGenius. Представим сценарий: пользователь прошёл fullscreen‑мастер, нажал «Подтвердить», и теперь ваш backend запускает довольно тяжёлый подбор — может быть, через MCP‑сервер вы дергаете несколько внешних API, пересчитываете цены, применяете кучу фильтров. Это может занять, скажем, 10–20 секунд.

С UX‑точки зрения не хочется 20 секунд держать пользователя в fullscreen с крутящимся спиннером. Лучше:

  1. Запустить подбор.
  2. Свернуть интерфейс в PiP, показывая прогресс.
  3. Дать пользователю возможность продолжать чат (например, задавать уточняющие вопросы).
  4. После завершения — вернуть результат inline или открыть новый fullscreen с подарками.

Сделаем простой хук, который будет управлять таким поведением:

const useLongGiftJob = () => {
  const [status, setStatus] = useState<"idle" | "running" | "done">("idle");
  const requestDisplayMode = useRequestDisplayMode();

  const startJob = async (payload: any) => {
    setStatus("running");
    const resultMode = await requestDisplayMode({ mode: "pip" });
    console.log("Фактический режим:", resultMode.mode);

    await callTool("run_gift_job", payload);
    setStatus("done");
    await requestDisplayMode({ mode: "inline" });
  };

  return { status, startJob };
};

Теперь в ReviewStep вместо прямого callTool мы используем этот хук:

const ReviewStep: React.FC<StepProps> = ({ state }) => {
  const { status, startJob } = useLongGiftJob();

  return (
    <div>
      {/* ...сводка... */}
      <button
        disabled={status === "running"}
        onClick={() => startJob(state)}
      >
        {status === "running" ? "Подбираем подарки…" : "Запустить подбор"}
      </button>
    </div>
  );
};

Чтобы статус фоновой задачи был доступен и fullscreen‑мастеру, и PiP‑окну, в реальном коде есть смысл вынести useLongGiftJob в контекст и читать его через useLongGiftJobContext. Детали реализации контекста (Provider, createContext) опустим: важно, что job‑state живёт в одном месте, а разные UI‑слои просто на него подписываются.

И отдельный компонент для PiP‑отображения:

const GiftPipView: React.FC<{ status: string }> = ({ status }) => {
  return (
    <div>
      <p>GiftGenius работает…</p>
      <p>Статус: {status === "running" ? "в процессе" : "готово"}</p>
      <button
        onClick={() => window.openai.requestDisplayMode({ mode: "fullscreen" })}
      >
        Развернуть
      </button>
    </div>
  );
};

В общем виджете мы подменим рендер так, чтобы учитывать и PiP:

const GiftGeniusWidget: React.FC = () => {
  const mode = useDisplayMode();
  const { status } = useLongGiftJobContext(); // через контекст, как обсуждали выше

  if (mode === "pip") {
    return <GiftPipView status={status} />;
  }

  if (mode === "fullscreen") {
    return <GiftFullscreenWizard />;
  }

  return <InlineGiftPreview onExpand={/* как раньше */} />;
};

Такой сценарий прекрасно сочетается с голосовыми режимами (о них поговорим в лекции про voice): голосом мы запускаем подбор, PiP показывает прогресс, чат остаётся внизу и продолжает жить своей жизнью.

9. Видео + чат: когда fullscreen и PiP превращаются в медиаплеер

Исторически PiP чаще всего ассоциируется с видео, которое висит в углу экрана поверх контента. Поэтому логично отдельно разобрать сценарий «video + chat». Здесь тоже нет никакой магии: в большинстве случаев вы просто отображаете видео в fullscreen или PiP‑окне. Документация OpenAI прямо приводит медиасценарии как типичный пример использования fullscreen и PiP.

Что это может значить для GiftGenius? Например:

  • вы показываете промо‑ролик подарка;
  • короткий туториал «как красиво упаковать подарок»;
  • видеообзор нескольких товаров.

В fullscreen можно отрендерить полноценный <video> с описанием и рекомендациями; в PiP — оставить только сам плеер и, возможно, маленький заголовок.

Простейший компонент‑обёртка:

const GiftVideoPlayer: React.FC<{ src: string; title: string }> = ({
  src,
  title,
}) => (
  <div>
    <h3>{title}</h3>
    <video
      src={src}
      controls
      style={{ width: "100%", borderRadius: 8 }}
    />
  </div>
);

В fullscreen‑мастере мы можем предложить пользователю «Посмотреть видео‑обзор этого подарка», а потом свернуть его в PiP:

const WatchVideoStep: React.FC = () => {
  const requestDisplayMode = useRequestDisplayMode();

  return (
    <div>
      <GiftVideoPlayer src="/videos/gift-wrap.mp4" title="Как упаковать подарок" />
      <button
        onClick={() => requestDisplayMode({ mode: "pip" })}
      >
        Оставить видео в углу и вернуться к чату
      </button>
    </div>
  );
};

Пара практических советов для медиасценариев:

  • не включайте автоплей со звуком — это универсальный UX-антипаттерн;
  • следите за субтитрами и возможностью ставить на паузу с клавиатуры (пробел, стрелки);
  • в PiP‑окне не пытайтесь показывать весь сопутствующий текст, ограничьтесь самим видео.

10. Состояние, пересоздание виджета и мобильные особенности

Самый неприятный вопрос, который обычно задают на этом этапе: «А React‑состояние сохранится, если я переключусь из inline в fullscreen и обратно?»

Короткий ответ: не полагайтесь на это.

Технически поведение зависит от версии SDK и реализации хоста: в одних случаях переход между режимами происходит без пересоздания iframe, в других — виджет при этом размонтируется и монтируется заново. В документации отдельно подчёркивается, что сохранение контекста при смене режимов зависит от конкретной реализации SDK и её версии и не является гарантией для разработчика.

Практический подход:

  1. Всё критичное состояние (шаг мастера, введённые данные, идентификатор фоновой задачи) храните либо:
    • в backend (через ваш MCP‑сервер и токены сессии),
    • либо в ChatGPT-контексте (например, через tools, которые возвращают «текущее состояние workflow»),
    • либо в URL‑параметрах/локальном сторе, если на это есть безопасное основание.
  2. React‑state используйте как кэш/слой UI, но будьте готовы, что при переключении режима он может обнулиться — тогда вы восстанавливаете его из более надёжного источника.

Вторая тонкость касается результата requestDisplayMode. Как уже упоминалось, запрос с mode "pip" может вернуться как "fullscreen", особенно на мобильных, где настоящий PiP может не поддерживаться или автоматически разворачиваться во весь экран.

Типичный шаблон:

const requestDisplayMode = useRequestDisplayMode();

const openPipSafe = async () => {
  const result = await requestDisplayMode({ mode: "pip" });
  if (result.mode !== "pip") {
    // Fallback: например, показать сообщение или адаптировать UI под fullscreen
    console.log("PiP недоступен, работаем в режиме:", result.mode);
  }
};

Так вы не окажетесь в ситуации, когда рассчитывали на маленькое окошко, а получили полноэкранный UI с «пип‑специфическими» кнопками. В таком режиме такой интерфейс будет выглядеть странно.

Наконец, помните про maxHeight и внутренний скролл: даже в fullscreen хост может ограничивать высоту контейнера, и ваша задача — организовать скролл так, чтобы не появилось три вложенных полосы прокрутки.

11. Типичные ошибки при работе с fullscreen и PiP

Ошибка №1: Fullscreen как режим по умолчанию.
Некоторые разработчики видят слово «fullscreen» и сразу пытаются превратить своё App в отдельное SPA внутри чата. В результате любое упоминание подарков — и пользователь мгновенно улетает в полноэкранный мастер, хотя хотел просто пару идей. Гайдлайны OpenAI настойчиво рекомендуют начинать с inline и только при объективной необходимости расширяться до fullscreen.

Ошибка №2: PiP как маленький fullscreen.
У PiP очень ограниченная площадь, но иногда в него пытаются запихнуть всё: вкладки, формы, фильтры. Пользователь получает микроскопический интерфейс, по которому невозможно попасть мышкой. Правильный подход — в PiP показывать только статус и одну-две ключевые кнопки (например, «Развернуть» и «Отменить»).

Ошибка №3: Необъяснённые переходы между режимами.
Когда виджет внезапно раскрывается в fullscreen без текста от GPT или без явного клика пользователя, это дезориентирует. То же справедливо для автосворачивания в PiP или возврата в inline. Каждый переход следует сопровождать коротким пояснением в сообщении модели: «Сейчас открою подробный мастер» перед fullscreen, «Сверну подбор в маленькое окно, пока он считается» перед PiP.

Ошибка №4: Игнорирование мобильных и различий платформ.
Разработчик тестирует только на десктопе, где PiP ведёт себя ожидаемо, а потом на мобильном всё превращается в fullscreen, верстка «едет», а кнопки оказываются за пределами safe‑area. Документация прямо предупреждает, что PiP на мобильном может быть реализован как fullscreen, а поведение может меняться между версиями SDK, поэтому тестирование на целевых устройствах и аккуратная работа с requestDisplayMode обязательны.

Ошибка №5: Полная вера в сохранность состояния при смене режима.
Опора только на React‑state без какой-либо серверной/персистентной поддержки приводит к смешным ситуациям: пользователь прошёл два шага мастера, нажал «Свернуть в PiP», а после возврата оказался на первом шаге с пустыми полями. Лучше считать, что при смене режима ваш компонент могут размонтировать, и проектировать state‑менеджмент с учётом этого риска.

Ошибка №6: Забытая доступность fullscreen‑мастера.
Красивая форма на большом экране не всегда удобна людям с ослабленным зрением или тем, кто пользуется только клавиатурой. Слишком мелкий текст, низкий контраст, нечитаемые кнопки «Далее» и «Назад» — частые причины не только плохого UX, но и проблем на ревью в Store. Стоит проверить хотя бы базовые вещи: контраст текста, размер шрифта, работа Tab‑навигации и наличие понятных текстовых меток для кнопок.

1
Задача
ChatGPT Apps, 8 уровень, 2 лекция
Недоступна
Mode Lab — индикатор displayMode и результат запроса переключения
Mode Lab — индикатор displayMode и результат запроса переключения
1
Задача
ChatGPT Apps, 8 уровень, 2 лекция
Недоступна
Fullscreen-мастер на 3 шага с сохранением прогресса в widgetState
Fullscreen-мастер на 3 шага с сохранением прогресса в widgetState
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ