feat(room): закрепление участника доступно из любого режима показа

Булавка закрепления раньше рендерилась только в «Стандарте» — в «Плитках»
и «Живых плитках» её не было вовсе, хотя крупной плитки для показа
закреплённого там и так нет. Теперь кнопка есть на любой плитке в любом
режиме, а сам клик закрепления (не открепления) переключает вид на
«Стандарт» — иначе закреплённого негде показать крупно.

Открепление обратно на предыдущий вид не переключает — это разовое
действие, а не устойчивый выбор пользователя, дёргать раскладку туда-обратно
не нужно. Переключение при закреплении не сохраняется в localStorage
(в отличие от явного выбора режима в тулбаре/настройках) по той же причине.

Заодно закрывает задачу «закрепление на мобильном»: раньше кнопки не было
в плиточных режимах даже там, а на тач-устройствах она и так всегда видна
(`@media (hover: none)`), отдельной мобильной доработки не потребовалось.
This commit is contained in:
2026-07-30 00:25:02 +03:00
parent 82204553e2
commit 43cc1ae3e8
2 changed files with 33 additions and 7 deletions

View File

@@ -179,6 +179,7 @@ export function RoomStage({
onHideOthers, onHideOthers,
initialFocusKey = null, initialFocusKey = null,
onFocusKeyChange, onFocusKeyChange,
onPinFocus,
}: { }: {
variant?: 'full' | 'pip' variant?: 'full' | 'pip'
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */ /** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
@@ -193,6 +194,14 @@ export function RoomStage({
initialFocusKey?: string | null initialFocusKey?: string | null
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */ /** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
onFocusKeyChange?: (key: string | null) => void onFocusKeyChange?: (key: string | null) => void
/**
* Участника только что закрепили (не открепили) в режиме без крупной
* плитки — сцена сама переключиться не может (режим живёт в `RoomPage`),
* поэтому просит переключить на `standard`, где закреплённого есть куда
* показать крупно. Открепление НЕ вызывает колбэк — вид назад не
* переключаем (см. докстринг `RoomPage`, обоснование решения в коммите).
*/
onPinFocus?: () => void
}) { }) {
const room = useRoomContext() const room = useRoomContext()
const isCompact = useIsCompactViewport() const isCompact = useIsCompactViewport()
@@ -337,9 +346,16 @@ export function RoomStage({
* Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает * Закрепить/открепить плитку: повторное нажатие на уже закреплённой снимает
* закрепление. Ключ приходит из самой плитки (она знает свой трек из * закрепление. Ключ приходит из самой плитки (она знает свой трек из
* контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов). * контекста — в карусели/гриде плитки рендерятся шаблоном, без пропсов).
*
* Закрепление доступно из ЛЮБОГО режима показа (задача 3.1 → 3.3): в
* плиточных режимах крупной плитки нет, поэтому само закрепление (не
* открепление) просит `RoomPage` переключить вид на `standard` —
* закреплённого иначе негде показать крупно.
*/ */
function handleTogglePin(key: string) { function handleTogglePin(key: string) {
setPinnedKey((prev) => (prev === key ? null : key)) const willPin = pinnedKey !== key
setPinnedKey(willPin ? key : null)
if (willPin) onPinFocus?.()
} }
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/ // Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
@@ -372,23 +388,23 @@ export function RoomStage({
: [] : []
: carouselTracks : carouselTracks
const showCarousel = !hideOthers && sideTracks.length > 0 const showCarousel = !hideOthers && sideTracks.length > 0
// Закрепление имеет смысл только там, где есть «крупная плитка» — // Булавка закрепления есть на КАЖДОЙ плитке во всех режимах (задача 3.3):
// в плиточных режимах фокуса нет, поэтому и булавки на плитках нет. // сама кнопка вызывает переключение на `standard`, где закреплённый и
const pinProps = // попадёт в фокус (см. `handleTogglePin`/`onPinFocus`).
effectiveMode === 'standard' ? { pinnedKey, onTogglePin: handleTogglePin } : {} const pinProps = { pinnedKey, onTogglePin: handleTogglePin }
function renderMain(): ReactNode { function renderMain(): ReactNode {
if (effectiveMode === 'tiles') { if (effectiveMode === 'tiles') {
return ( return (
<StageGrid tracks={tracks}> <StageGrid tracks={tracks}>
<RoomParticipantTile /> <RoomParticipantTile {...pinProps} />
</StageGrid> </StageGrid>
) )
} }
if (effectiveMode === 'live-tiles') { if (effectiveMode === 'live-tiles') {
return ( return (
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}> <StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
<RoomParticipantTile /> <RoomParticipantTile {...pinProps} />
</StageGrid> </StageGrid>
) )
} }

View File

@@ -169,6 +169,15 @@ export function RoomPage() {
saveStageLayoutMode(mode) saveStageLayoutMode(mode)
}, []) }, [])
// Закрепление участника из плиточного режима переключает вид на «Стандарт»
// (см. докстринг `RoomStage`, `onPinFocus`) — но это НЕ то же самое, что
// явный выбор пользователя: переключение не сохраняется в localStorage
// (в отличие от `handleLayoutModeChange`), иначе разовое закрепление в этой
// встрече поменяло бы устойчивое предпочтение режима для всех следующих.
const handlePinFocus = useCallback(() => {
setLayoutMode('standard')
}, [])
// Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только // Ключ трека, который сцена показывает крупно. Живёт ЗДЕСЬ, а не только
// внутри `RoomStage`, потому что при открытии мини-плеера сцена // внутри `RoomStage`, потому что при открытии мини-плеера сцена
// размонтируется в основном окне и монтируется заново в PiP-окне (портал // размонтируется в основном окне и монтируется заново в PiP-окне (портал
@@ -267,6 +276,7 @@ export function RoomPage() {
onHideOthers={() => setHideOthers(true)} onHideOthers={() => setHideOthers(true)}
initialFocusKey={stageFocusKey} initialFocusKey={stageFocusKey}
onFocusKeyChange={setStageFocusKey} onFocusKeyChange={setStageFocusKey}
onPinFocus={handlePinFocus}
/> />
)} )}
{chatVisible && chatOpen && ( {chatVisible && chatOpen && (