fix(stream-header): fix the Android WebView pull-to-refresh flicker and rework the dancing-mascot loop and dwell
This commit is contained in:
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.
|
|
@ -22,6 +22,11 @@ type RefreshMascotProps = {
|
||||||
// poster is the clip's first frame, so the poster↔video swap is
|
// poster is the clip's first frame, so the poster↔video swap is
|
||||||
// invisible and the dance starts seamlessly on reveal.
|
// invisible and the dance starts seamlessly on reveal.
|
||||||
revealing: boolean;
|
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:
|
// 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
|
// 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
|
// + aria-hidden, so a mid-session OS toggle not re-evaluating here is
|
||||||
// acceptable.)
|
// acceptable.)
|
||||||
export function RefreshMascot({ caption, revealing }: RefreshMascotProps) {
|
export function RefreshMascot({ caption, revealing, videoRef }: RefreshMascotProps) {
|
||||||
const native = isNativePlatform();
|
const native = isNativePlatform();
|
||||||
|
|
||||||
const reducedMotion = useMemo(
|
const reducedMotion = useMemo(
|
||||||
|
|
@ -66,6 +71,7 @@ export function RefreshMascot({ caption, revealing }: RefreshMascotProps) {
|
||||||
whole light-blue chrome. This card holds just the mascot + caption. */}
|
whole light-blue chrome. This card holds just the mascot + caption. */}
|
||||||
{showVideo ? (
|
{showVideo ? (
|
||||||
<video
|
<video
|
||||||
|
ref={videoRef}
|
||||||
className={css.mascotMedia}
|
className={css.mascotMedia}
|
||||||
src={mascotWebm}
|
src={mascotWebm}
|
||||||
poster={mascotPoster}
|
poster={mascotPoster}
|
||||||
|
|
|
||||||
|
|
@ -314,20 +314,26 @@ export const mascotCard = style({
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Radar-pulse overlay. Spans the visible light-blue surface behind the
|
// Radar-pulse overlay — pink concentric rings rippling across the light-blue
|
||||||
// curtain — up into the safe-top (lifted in via the negative `top`) and
|
// surface behind the curtain. Rendered as ONE <svg> of 10 <circle>s, NOT 10
|
||||||
// down to the stage bottom — so the ripple washes across the app chrome.
|
// CSS-animated <span>s: each animated span is promoted to its own GPU layer,
|
||||||
// `zIndex: 1` is over the header chrome but under the curtain (`zIndex: 2`),
|
// and ten such layers blow Chromium's `cc` tile-memory budget on Android
|
||||||
// which occludes whatever's below its top edge wherever it's dragged.
|
// WebView ("tile memory limits exceeded, some content may not draw") → the
|
||||||
// `pointerEvents: none` + low opacity keep the tabs / mascot readable.
|
// curtain flickers (device logcat: that warning fired 572× only while the
|
||||||
// Rendered only while revealed + animated (StreamHeader `showPulse`).
|
// rings animated). Chrome does NOT split an SVG into per-element GPU layers,
|
||||||
const pulseKeyframes = keyframes({
|
// so all ten rings share ONE layer and the tile budget holds.
|
||||||
'0%': { transform: 'translate(-50%, -50%) scale(0.05)', opacity: 0 },
|
//
|
||||||
'10%': { opacity: 0.26 },
|
// (A <canvas> also collapses to one layer and likewise zeroed the tile
|
||||||
'65%': { opacity: 0.13 },
|
// warning — but this WebView composites a full-screen, every-frame 2D canvas
|
||||||
'100%': { transform: 'translate(-50%, -50%) scale(1)', opacity: 0 },
|
// 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({
|
export const stagePulseLayer = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 'calc(-1 * var(--vojo-safe-top, 0px))',
|
top: 'calc(-1 * var(--vojo-safe-top, 0px))',
|
||||||
|
|
@ -339,20 +345,41 @@ export const stagePulseLayer = style({
|
||||||
zIndex: 1,
|
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',
|
position: 'absolute',
|
||||||
left: '50%',
|
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)})`,
|
top: `calc(var(--vojo-safe-top, 0px) + ${toRem(TABS_ROW_PX + 53)})`,
|
||||||
width: toRem(760),
|
width: toRem(760),
|
||||||
height: toRem(760),
|
height: toRem(760),
|
||||||
borderRadius: '50%',
|
overflow: 'visible',
|
||||||
border: `${toRem(2)} solid rgb(248, 80, 160)`,
|
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,
|
opacity: 0,
|
||||||
transform: 'translate(-50%, -50%) scale(0.05)',
|
animationName: ringKeyframes,
|
||||||
animationName: pulseKeyframes,
|
|
||||||
animationDuration: '4s',
|
animationDuration: '4s',
|
||||||
animationTimingFunction: 'ease-out',
|
animationTimingFunction: 'ease-out',
|
||||||
animationIterationCount: 'infinite',
|
animationIterationCount: 'infinite',
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,9 @@ export function StreamHeader({
|
||||||
// `!curtain.isDragging`, so a deferred close fires the instant a
|
// `!curtain.isDragging`, so a deferred close fires the instant a
|
||||||
// mid-refresh retract drag releases (spring-back included — no strand).
|
// mid-refresh retract drag releases (spring-back included — no strand).
|
||||||
const refreshing = curtain.snap === 'refresh';
|
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);
|
const [refreshReady, setRefreshReady] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!refreshing) {
|
if (!refreshing) {
|
||||||
|
|
@ -293,12 +296,30 @@ export function StreamHeader({
|
||||||
};
|
};
|
||||||
}, [refreshing, mx]);
|
}, [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
|
// `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;
|
const refreshDragging = curtain.isDragging;
|
||||||
useEffect(() => {
|
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]);
|
}, [refreshing, refreshReady, refreshDragging, close]);
|
||||||
|
|
||||||
// Memoised controls object so the cleanup's identity check (atom
|
// Memoised controls object so the cleanup's identity check (atom
|
||||||
|
|
@ -552,6 +573,7 @@ export function StreamHeader({
|
||||||
<RefreshMascot
|
<RefreshMascot
|
||||||
caption={t('Direct.refreshing')}
|
caption={t('Direct.refreshing')}
|
||||||
revealing={isRefreshSnap(curtain.snap) || curtain.isDragging}
|
revealing={isRefreshSnap(curtain.snap) || curtain.isDragging}
|
||||||
|
videoRef={mascotVideoRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -559,14 +581,26 @@ export function StreamHeader({
|
||||||
{/* ── Radar-pulse overlay ────────────────────────────────
|
{/* ── Radar-pulse overlay ────────────────────────────────
|
||||||
Concentric pink rings rippling across the light-blue surface behind
|
Concentric pink rings rippling across the light-blue surface behind
|
||||||
the curtain (z:1, the curtain at z:2 occludes whatever's below its
|
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,
|
top edge). ONE <svg> of 10 <circle>s rather than 10 animated <span>s:
|
||||||
continuous ripple; `pointerEvents: none` so taps fall through to the
|
10 animated spans each become their own GPU layer and blow Chromium's
|
||||||
tabs. See `stagePulseLayer`. */}
|
`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 && (
|
{showPulse && (
|
||||||
<div className={css.stagePulseLayer}>
|
<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) => (
|
{[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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,13 @@ export const CURTAIN_RADIUS_PX = 24;
|
||||||
export const REFRESH_COMMIT_PX = 88;
|
export const REFRESH_COMMIT_PX = 88;
|
||||||
|
|
||||||
// Refresh dance timing. The mascot stays revealed for AT LEAST
|
// Refresh dance timing. The mascot stays revealed for AT LEAST
|
||||||
// `REFRESH_MIN_MS` so a near-instant /sync (healthy online client) reads
|
// `REFRESH_MIN_MS` — two full turns of the 2.0s mascot loop — so the pull
|
||||||
// as an intentional refresh rather than a flicker; it auto-closes once
|
// reads as a deliberate ≥2-cycle dance, not a flicker. Paired with the
|
||||||
// /sync returns AND that minimum has elapsed. `REFRESH_MAX_MS` caps the
|
// loop-boundary close (StreamHeader reads `video.currentTime`), it then ends
|
||||||
// dance so an offline client (sync never returns) can't strand the
|
// on a clean cycle once /sync returns AND this minimum has elapsed.
|
||||||
// curtain open — it closes regardless after the cap.
|
// `REFRESH_MAX_MS` caps the dance so an offline client (sync never returns)
|
||||||
export const REFRESH_MIN_MS = 1200;
|
// can't strand the curtain open — it closes regardless after the cap.
|
||||||
|
export const REFRESH_MIN_MS = 4000;
|
||||||
export const REFRESH_MAX_MS = 8000;
|
export const REFRESH_MAX_MS = 8000;
|
||||||
|
|
||||||
// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion on
|
// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion on
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue