diff --git a/apps/overlay/src/control.css b/apps/overlay/src/control.css index cb653c4..cafdf81 100644 --- a/apps/overlay/src/control.css +++ b/apps/overlay/src/control.css @@ -927,6 +927,13 @@ body, white-space: normal; } +/* In compact-only mode the whole bottom-anchored stack is moved with FLIP in + overlay.tsx. Suppress the per-card side entrance so the new row appears to + rise from below the component while the existing rows are pushed upward. */ +.overlay.stack-arrival .card.danmaku.compact { + animation: none; +} + .card.danmaku.expanded .copy, .card.danmaku.compact .copy { padding-inline-end: clamp(38px, 8%, 58px); diff --git a/apps/overlay/src/overlay.tsx b/apps/overlay/src/overlay.tsx index dc67482..22dadaa 100644 --- a/apps/overlay/src/overlay.tsx +++ b/apps/overlay/src/overlay.tsx @@ -7,7 +7,7 @@ * component settings, and malformed frames are ignored without terminating a * long-running browser source. */ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties } from 'react' import type { ComponentStream } from './stream' import { translate, useI18n } from './i18n' @@ -335,6 +335,9 @@ function Card({ export function Overlay({ preview = false, previewSettings, stream }: OverlayProps) { const { language } = useI18n() const root = useRef(null) + const cardsRef = useRef(null) + const previousCardTopsRef = useRef(new Map()) + const stackAnimationRef = useRef(null) const events = useEvents(preview, stream) const { items, setItems } = events const settings = previewSettings || events.settings @@ -408,10 +411,88 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro return () => window.clearTimeout(timer) }, [items, settings.collapseAfterSeconds, settings.expandNewDanmaku, shape]) + useLayoutEffect(() => { + const cards = cardsRef.current + if (!cards) return + + const elements = Array.from(cards.children).filter( + (element): element is HTMLElement => element instanceof HTMLElement, + ) + const previousTops = previousCardTopsRef.current + const hasNewDanmaku = items.some( + item => item.type === 'live.danmaku' && !previousTops.has(item.key), + ) + + // If another push is still running, carry its current offset into the + // next FLIP animation so bursts of chat do not snap between positions. + let runningOffset = 0 + const transform = getComputedStyle(cards).transform + if (transform !== 'none') { + try { + runningOffset = new DOMMatrixReadOnly(transform).m42 + } catch { + runningOffset = 0 + } + } + stackAnimationRef.current?.cancel() + + const currentTops = new Map() + items.forEach((item, index) => { + const element = elements[index] + if (element) currentTops.set(item.key, element.getBoundingClientRect().top) + }) + + const reducedMotion = + settings.lowPerformanceMode || + (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false) + if (!settings.expandNewDanmaku && hasNewDanmaku && !reducedMotion && elements.length > 0) { + const anchorIndex = items.findIndex(item => previousTops.has(item.key)) + const anchor = anchorIndex >= 0 ? elements[anchorIndex] : undefined + const anchorKey = anchorIndex >= 0 ? items[anchorIndex]?.key : undefined + const previousTop = anchorKey ? previousTops.get(anchorKey) : undefined + const currentTop = anchor?.getBoundingClientRect().top + const gap = Number.parseFloat(getComputedStyle(cards).rowGap) || 0 + const fallbackOffset = cards.getBoundingClientRect().height + gap + const pushOffset = Math.max( + 0, + previousTop !== undefined && currentTop !== undefined + ? previousTop + runningOffset - currentTop + : fallbackOffset, + ) + + if (pushOffset > 0.5) { + const duration = 350 + settings.motionIntensity * 3.5 + const animation = cards.animate( + [ + { transform: `translate3d(0, ${pushOffset}px, 0)` }, + { transform: 'translate3d(0, 0, 0)' }, + ], + { + duration, + easing: 'cubic-bezier(0.2, 0.82, 0.24, 1)', + }, + ) + stackAnimationRef.current = animation + animation.addEventListener('finish', () => { + if (stackAnimationRef.current === animation) stackAnimationRef.current = null + }) + } + } + + previousCardTopsRef.current = currentTops + }, [items, settings.expandNewDanmaku, settings.lowPerformanceMode, settings.motionIntensity]) + + useEffect( + () => () => { + stackAnimationRef.current?.cancel() + }, + [], + ) + return (
-
+
{items.map(item => (