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

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

ChatGPT Apps
Рівень 8 , Лекція 2
Відкрита

2. Навіщо взагалі потрібен fullscreen, якщо є inline?

У попередній лекції про inline ми вже домовилися: якщо завдання коротке й уміщується у 5–7 обʼєктів або на один екран, inline‑картка — ідеальний варіант. Список із кількох подарунків, пара фільтрів, одна‑дві кнопки — усе це чудово працює просто в потоці повідомлень.

Але в будь‑якого застосунку настає момент, коли «ще одна картка» вже не рятує:

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

Inline тут починає «нервово смикатися»: ширина обмежена колонкою чату, висота — теж, навігації немає, та й прокрутка в чаті одна на всіх. Саме для таких сценаріїв в Apps SDK є режим fullscreen — «занурений» інтерфейс, у якому ваш віджет займає більшу частину екрана й може показувати складні компонування.

Другий герой сьогоднішньої лекції — 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 — навчальний застосунок із попередніх модулів, який зараз показує 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‑екрані в нас є місце для:

  • progress bar або 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.

Кілька важливих особливостей, про які варто знати з документації та з досвіду перших користувачів:

  1. У PiP дуже мало місця. Це не простір для форм і складних компонувань, а радше місце для двох‑трьох ключових метрик і однієї‑двох кнопок.
  2. На десктопі PiP «прилипає» вгорі й залишається видимим за будь‑якої прокрутки. На мобільних же він часто автоматично перетворюється на fullscreen.
  3. Запит requestDisplayMode із mode "pip" не гарантує справжнього PiP. Платформа може повернути інший режим (наприклад, fullscreen) або взагалі поводитися дивно на старих версіях SDK. Тож завжди перевіряйте результат промісу й майте fallback-варіант.

Звідси простий UX‑висновок: у PiP — тільки найважливіше. Таймер, індикатор доставки, статус задачі, кнопка «Розгорнути». Жодних 12 прапорців, таблиць на 10 колонок і «зробіть мені ще каву».

8. GiftGenius + PiP: довгий пошук і фоновий прогрес

Повернімося до GiftGenius. Уявімо сценарій: користувач пройшов fullscreen‑майстер, натиснув «Підтвердити», і тепер ваш бекенд запускає доволі важкий підбір — можливо, через 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) опустимо: важливо, що стан задачі живе в одному місці, а різні 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. Увесь критичний стан (крок майстра, введені дані, ідентифікатор фонової задачі) зберігайте або:
    • у бекенді (через ваш MCP‑сервер і токени сесії),
    • або в контексті ChatGPT (наприклад, через tools, які повертають «поточний стан робочого процесу»),
    • або в 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 із «pip‑специфічними» кнопками. У такому режимі інтерфейс виглядатиме дивно.

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

11. Типові помилки під час роботи з fullscreen і PiP

Помилка № 1: Fullscreen як режим за замовчуванням.
Деякі розробники бачать слово «fullscreen» і відразу намагаються перетворити свій застосунок на окреме 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», а після повернення опинився на першому кроці з порожніми полями. Краще виходити з того, що під час зміни режиму ваш компонент можуть демонтувати, і проєктувати керування станом з урахуванням цього ризику.

Помилка № 6: Забута доступність fullscreen‑майстра.
Гарна форма на великому екрані не завжди зручна людям зі зниженим зором або тим, хто користується лише клавіатурою. Надто дрібний текст, низький контраст, нечіткі кнопки «Далі» і «Назад» — часті причини не лише поганого UX, а й проблем на ревʼю в Store. Варто перевірити бодай базові речі: контраст тексту, розмір шрифту, роботу навігації Tab і наявність зрозумілих текстових міток для кнопок.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ