fix(stream-header): fix the Android WebView pull-to-refresh flicker and rework the dancing-mascot loop and dwell

This commit is contained in:
heaven 2026-06-08 21:56:36 +03:00
parent b6ffa03891
commit 47255c6bf1
6 changed files with 106 additions and 38 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

View file

@ -22,6 +22,11 @@ type RefreshMascotProps = {
// poster is the clip's first frame, so the poster↔video swap is
// invisible and the dance starts seamlessly on reveal.
revealing: boolean;
// Forwarded to the dance <video> so StreamHeader can read the loop position
// and defer the pull-to-refresh auto-close to a loop boundary — the curtain
// never rises mid-dance. Null while the still poster (reduced-motion /
// not-yet-revealed) is shown instead of the video.
videoRef?: React.Ref<HTMLVideoElement>;
};
// The card revealed below the tabs row by the curtain's `refresh` snap:
@ -44,7 +49,7 @@ type RefreshMascotProps = {
// of the app reads this query one-shot too, and this card is native-only
// + aria-hidden, so a mid-session OS toggle not re-evaluating here is
// acceptable.)
export function RefreshMascot({ caption, revealing }: RefreshMascotProps) {
export function RefreshMascot({ caption, revealing, videoRef }: RefreshMascotProps) {
const native = isNativePlatform();
const reducedMotion = useMemo(
@ -66,6 +71,7 @@ export function RefreshMascot({ caption, revealing }: RefreshMascotProps) {
whole light-blue chrome. This card holds just the mascot + caption. */}
{showVideo ? (
<video
ref={videoRef}
className={css.mascotMedia}
src={mascotWebm}
poster={mascotPoster}

View file

@ -314,20 +314,26 @@ export const mascotCard = style({
backgroundColor: color.SurfaceVariant.Container,
});
// Radar-pulse overlay. Spans the visible light-blue surface behind the
// curtain — up into the safe-top (lifted in via the negative `top`) and
// down to the stage bottom — so the ripple washes across the app chrome.
// `zIndex: 1` is over the header chrome but under the curtain (`zIndex: 2`),
// which occludes whatever's below its top edge wherever it's dragged.
// `pointerEvents: none` + low opacity keep the tabs / mascot readable.
// Rendered only while revealed + animated (StreamHeader `showPulse`).
const pulseKeyframes = keyframes({
'0%': { transform: 'translate(-50%, -50%) scale(0.05)', opacity: 0 },
'10%': { opacity: 0.26 },
'65%': { opacity: 0.13 },
'100%': { transform: 'translate(-50%, -50%) scale(1)', opacity: 0 },
});
// Radar-pulse overlay — pink concentric rings rippling across the light-blue
// surface behind the curtain. Rendered as ONE <svg> of 10 <circle>s, NOT 10
// CSS-animated <span>s: each animated span is promoted to its own GPU layer,
// and ten such layers blow Chromium's `cc` tile-memory budget on Android
// WebView ("tile memory limits exceeded, some content may not draw") → the
// curtain flickers (device logcat: that warning fired 572× only while the
// rings animated). Chrome does NOT split an SVG into per-element GPU layers,
// so all ten rings share ONE layer and the tile budget holds.
//
// (A <canvas> also collapses to one layer and likewise zeroed the tile
// warning — but this WebView composites a full-screen, every-frame 2D canvas
// as an OPAQUE surface: solid white over the mascot. SVG paints in the normal
// tree like the old spans did, so it stays transparent.)
//
// This clip layer spans the visible surface — up into the safe-top (negative
// `top`) and down to the stage bottom — and clips the rings at the status-bar
// edge. `zIndex: 1` is over the header chrome but under the curtain
// (`zIndex: 2`), which occludes whatever's below its top edge. `pointerEvents:
// none` lets taps fall through. Rendered only while revealed + animated
// (StreamHeader `showPulse`).
export const stagePulseLayer = style({
position: 'absolute',
top: 'calc(-1 * var(--vojo-safe-top, 0px))',
@ -339,20 +345,41 @@ export const stagePulseLayer = style({
zIndex: 1,
});
export const stagePulse = style({
// One ring's scale + fade — the old span keyframes, byte-for-byte: scale
// 0.05→1 (the 2px stroke scales WITH it → 0.1px when tiny, a crisp 2px at
// full size — the approved look), opacity 0 → 0.26 → 0.13 → 0.
const ringKeyframes = keyframes({
'0%': { transform: 'scale(0.05)', opacity: 0 },
'10%': { opacity: 0.26 },
'65%': { opacity: 0.13 },
'100%': { transform: 'scale(1)', opacity: 0 },
});
// The 760² ring SVG, centred on ≈ the mascot centre (safe-top + tabs row +
// the card's top pad + half the video box). `translateZ(0)` keeps the rings'
// per-frame raster isolated to this one small layer rather than repainting
// the whole stage; `overflow: visible` so the full-scale stroke isn't clipped.
export const stagePulseSvg = style({
position: 'absolute',
left: '50%',
// Origin ≈ the mascot centre. The layer top is lifted by the safe-top, so
// add it back: safe-top + tabs row + the card's top pad + half the video
// box (≈ TABS_ROW_PX + 53 with the current card geometry).
top: `calc(var(--vojo-safe-top, 0px) + ${toRem(TABS_ROW_PX + 53)})`,
width: toRem(760),
height: toRem(760),
borderRadius: '50%',
border: `${toRem(2)} solid rgb(248, 80, 160)`,
overflow: 'visible',
transform: 'translate(-50%, -50%) translateZ(0)',
});
// Each concentric ring. `transformBox: fill-box` + `transformOrigin: center`
// so `scale()` grows it about its own centre. NO `non-scaling-stroke` — the
// stroke scales with the circle, matching the approved span look.
export const stagePulseRing = style({
fill: 'none',
stroke: 'rgb(248, 80, 160)',
strokeWidth: 2,
transformBox: 'fill-box',
transformOrigin: 'center',
opacity: 0,
transform: 'translate(-50%, -50%) scale(0.05)',
animationName: pulseKeyframes,
animationName: ringKeyframes,
animationDuration: '4s',
animationTimingFunction: 'ease-out',
animationIterationCount: 'infinite',

View file

@ -254,6 +254,9 @@ export function StreamHeader({
// `!curtain.isDragging`, so a deferred close fires the instant a
// mid-refresh retract drag releases (spring-back included — no strand).
const refreshing = curtain.snap === 'refresh';
// Ref to the dance <video> so the close effect can align the auto-close to
// a loop boundary (see below). Null while the still poster is shown.
const mascotVideoRef = useRef<HTMLVideoElement>(null);
const [refreshReady, setRefreshReady] = useState(false);
useEffect(() => {
if (!refreshing) {
@ -293,12 +296,30 @@ export function StreamHeader({
};
}, [refreshing, mx]);
// Close once the dance is ready AND no drag is in flight. Re-runs when
// Close once the dance is ready AND no drag is in flight — but only at a
// mascot LOOP BOUNDARY, so the curtain never retracts mid-dance. When the
// dance becomes ready we read its position and defer the close by the time
// left in the current loop (`(duration currentTime) / playbackRate`); the
// clip is a seamless 2 s loop, so any wrap point is a clean stop. If there's
// no live video (reduced-motion poster, or it stalled / hasn't mounted) or
// it's essentially at the boundary already, close immediately. Re-runs when
// `isDragging` flips, so a close deferred during a mid-refresh retract
// fires the moment the finger releases (whether it sprang back or not).
// re-arms the moment the finger releases (whether it sprang back or not).
const refreshDragging = curtain.isDragging;
useEffect(() => {
if (refreshing && refreshReady && !refreshDragging) close();
if (!(refreshing && refreshReady && !refreshDragging)) return undefined;
const video = mascotVideoRef.current;
if (!video || video.paused || !Number.isFinite(video.duration) || video.duration <= 0) {
close();
return undefined;
}
const remainingMs = ((video.duration - video.currentTime) / (video.playbackRate || 1)) * 1000;
if (remainingMs <= 30) {
close();
return undefined;
}
const timer = window.setTimeout(close, remainingMs);
return () => window.clearTimeout(timer);
}, [refreshing, refreshReady, refreshDragging, close]);
// Memoised controls object so the cleanup's identity check (atom
@ -552,6 +573,7 @@ export function StreamHeader({
<RefreshMascot
caption={t('Direct.refreshing')}
revealing={isRefreshSnap(curtain.snap) || curtain.isDragging}
videoRef={mascotVideoRef}
/>
)}
</header>
@ -559,14 +581,26 @@ export function StreamHeader({
{/* Radar-pulse overlay
Concentric pink rings rippling across the light-blue surface behind
the curtain (z:1, the curtain at z:2 occludes whatever's below its
top edge). 10 rings staggered across the 4s loop for a dense,
continuous ripple; `pointerEvents: none` so taps fall through to the
tabs. See `stagePulseLayer`. */}
top edge). ONE <svg> of 10 <circle>s rather than 10 animated <span>s:
10 animated spans each become their own GPU layer and blow Chromium's
`cc` tile-memory budget on Android WebView ("some content may not
draw") flicker. Chrome doesn't split an SVG into per-element layers,
so the rings share one layer. `pointerEvents: none` so taps fall
through to the tabs. See `stagePulseLayer`. */}
{showPulse && (
<div className={css.stagePulseLayer}>
<svg className={css.stagePulseSvg} viewBox="0 0 760 760" aria-hidden="true">
{[0, 0.4, 0.8, 1.2, 1.6, 2.0, 2.4, 2.8, 3.2, 3.6].map((delay) => (
<span key={delay} className={css.stagePulse} style={{ animationDelay: `${delay}s` }} />
<circle
key={delay}
className={css.stagePulseRing}
cx={380}
cy={380}
r={380}
style={{ animationDelay: `${delay}s` }}
/>
))}
</svg>
</div>
)}

View file

@ -96,12 +96,13 @@ export const CURTAIN_RADIUS_PX = 24;
export const REFRESH_COMMIT_PX = 88;
// Refresh dance timing. The mascot stays revealed for AT LEAST
// `REFRESH_MIN_MS` so a near-instant /sync (healthy online client) reads
// as an intentional refresh rather than a flicker; it auto-closes once
// /sync returns AND that minimum has elapsed. `REFRESH_MAX_MS` caps the
// dance so an offline client (sync never returns) can't strand the
// curtain open — it closes regardless after the cap.
export const REFRESH_MIN_MS = 1200;
// `REFRESH_MIN_MS` — two full turns of the 2.0s mascot loop — so the pull
// reads as a deliberate ≥2-cycle dance, not a flicker. Paired with the
// loop-boundary close (StreamHeader reads `video.currentTime`), it then ends
// on a clean cycle once /sync returns AND this minimum has elapsed.
// `REFRESH_MAX_MS` caps the dance so an offline client (sync never returns)
// can't strand the curtain open — it closes regardless after the cap.
export const REFRESH_MIN_MS = 4000;
export const REFRESH_MAX_MS = 8000;
// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion on